Add worktrees, per-hunk review, effort per message, regenerate, voice input
- Isolated git worktree per session (create, status, merge, discard) - Permission card: accept file-edit hunks individually; agent learns what was rejected - Reasoning effort per message; regenerate the last answer with another model/effort - Voice input via the Web Speech API - Folder-up button in the file browser; ssh, rsync and mc in the image Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df1924b0a1
commit
fca1c41087
22 files changed
+821
-32
No files matched your search
@@ -377,3 +377,103 @@ mod tests {
|
||||
assert!(!valid_ref("a b"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- worktrees per session -------------------------------------------------------
|
||||
|
||||
fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
||||
wt[key].as_str().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Create, inspect, merge or remove the session's isolated worktree.
|
||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(app, user);
|
||||
let s = session.clone();
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||
session.cwd = cwd.to_string();
|
||||
}
|
||||
if res.get("worktree").is_some() {
|
||||
session.worktree = res["worktree"].clone().as_object().map(|_| res["worktree"].clone());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn worktree_inner(session: crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||
let bad = |m: &str| ApiError(StatusCode::BAD_REQUEST, m.to_string());
|
||||
match action {
|
||||
"create" => {
|
||||
if session.worktree.is_some() {
|
||||
return Err(bad("this session already has a worktree"));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let repo = repo_root(&cwd).ok_or_else(|| bad("the session folder is not in a git repository"))?;
|
||||
let sub = cwd.strip_prefix(&repo).map(|p| p.to_path_buf()).unwrap_or_default();
|
||||
let short: String = session.id.chars().rev().take(8).collect::<String>().chars().rev().collect();
|
||||
let branch = format!("codecraft/{short}");
|
||||
let path = repo.join(".codecraft").join("worktrees").join(&short);
|
||||
let base = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
|
||||
if base == "HEAD" {
|
||||
return Err(bad("the repository is in detached HEAD state – check out a branch first"));
|
||||
}
|
||||
// Keep the worktrees out of the repository's status (local only, not committed).
|
||||
let common = git_ok(&repo, &["rev-parse", "--git-common-dir"], 30).await?.trim().to_string();
|
||||
let common = if Path::new(&common).is_absolute() { PathBuf::from(common) } else { repo.join(common) };
|
||||
let exclude = common.join("info").join("exclude");
|
||||
let current = std::fs::read_to_string(&exclude).unwrap_or_default();
|
||||
if !current.lines().any(|l| l.trim() == ".codecraft/") {
|
||||
let _ = std::fs::create_dir_all(exclude.parent().unwrap());
|
||||
let _ = std::fs::write(&exclude, format!("{current}{}.codecraft/\n", if current.is_empty() || current.ends_with('\n') { "" } else { "\n" }));
|
||||
}
|
||||
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| bad(&e.to_string()))?;
|
||||
let p = path.display().to_string();
|
||||
git_ok(&repo, &["worktree", "add", "-b", &branch, &p, "HEAD"], 120).await?;
|
||||
let wt = json!({"repo": repo, "path": path, "branch": branch, "base": base, "sub": sub});
|
||||
let cwd = if sub.as_os_str().is_empty() { path.clone() } else { path.join(&sub) };
|
||||
Ok(json!({"ok": true, "worktree": wt, "cwd": cwd}))
|
||||
}
|
||||
"status" => {
|
||||
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
|
||||
let path = PathBuf::from(wt_str(&wt, "path"));
|
||||
let dirty = git_ok(&path, &["status", "--porcelain"], 60).await?.lines().count();
|
||||
let range = format!("{}..{}", wt_str(&wt, "base"), wt_str(&wt, "branch"));
|
||||
let commits = git_ok(&path, &["log", "--oneline", &range], 60).await.unwrap_or_default();
|
||||
let stat = git_ok(&path, &["diff", "--shortstat", wt_str(&wt, "base")], 60).await.unwrap_or_default();
|
||||
Ok(json!({"worktree": wt, "dirty_files": dirty, "commits": commits.lines().collect::<Vec<_>>(), "diffstat": stat.trim()}))
|
||||
}
|
||||
"merge" => {
|
||||
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
|
||||
let path = PathBuf::from(wt_str(&wt, "path"));
|
||||
let repo = PathBuf::from(wt_str(&wt, "repo"));
|
||||
let (branch, base) = (wt_str(&wt, "branch"), wt_str(&wt, "base"));
|
||||
// Uncommitted work in the worktree is committed first.
|
||||
if !git_ok(&path, &["status", "--porcelain"], 60).await?.trim().is_empty() {
|
||||
git_ok(&path, &["add", "-A"], 120).await?;
|
||||
let msg = format!("CodeCraft: {}", if session.title.is_empty() { "session changes" } else { session.title.as_str() });
|
||||
git_ok(&path, &["commit", "-q", "-m", &msg], 120).await?;
|
||||
}
|
||||
let current = git_ok(&repo, &["rev-parse", "--abbrev-ref", "HEAD"], 30).await?.trim().to_string();
|
||||
if current != base {
|
||||
return Err(bad(&format!("the main working copy is on '{current}', not on '{base}' – check out '{base}' first")));
|
||||
}
|
||||
let msg = format!("Merge {branch}{}", if session.title.is_empty() { String::new() } else { format!(": {}", session.title) });
|
||||
let o = git(&repo, &["merge", "--no-ff", "-m", &msg, branch], 300).await?;
|
||||
if !o.ok {
|
||||
let _ = git(&repo, &["merge", "--abort"], 60).await;
|
||||
return Err(ApiError(StatusCode::CONFLICT, format!("merge failed (aborted, nothing changed): {}", if o.stderr.trim().is_empty() { o.stdout.trim() } else { o.stderr.trim() })));
|
||||
}
|
||||
Ok(json!({"ok": true, "merged": branch, "into": base, "output": o.stdout.trim()}))
|
||||
}
|
||||
"remove" => {
|
||||
let wt = session.worktree.clone().ok_or_else(|| bad("no worktree"))?;
|
||||
let repo = PathBuf::from(wt_str(&wt, "repo"));
|
||||
let p = wt_str(&wt, "path").to_string();
|
||||
git_ok(&repo, &["worktree", "remove", "--force", &p], 120).await?;
|
||||
let _ = git(&repo, &["branch", "-D", wt_str(&wt, "branch")], 60).await;
|
||||
let sub = wt_str(&wt, "sub");
|
||||
let cwd = if sub.is_empty() { repo.clone() } else { repo.join(sub) };
|
||||
Ok(json!({"ok": true, "worktree": Value::Null, "cwd": if cwd.is_dir() { cwd } else { repo }}))
|
||||
}
|
||||
_ => Err(bad("unknown worktree action")),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user