From 5e7ee5e08139b7ad8b06326f7672e75d908547e2 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 15:25:45 +0800 Subject: [PATCH] fix(agents): delete agent tasks whose worktree git no longer recognizes (#979) Deleting a worktree-isolated agent task together with its worktree failed for good with "fatal: '' is not a working tree" once git no longer recognized the worktree: its registration was pruned, the repository moved, or the worktree's `.git` file was deleted. When `git worktree remove --force` fails but the folder is still there and lies inside the agent worktrees directory, the folder is now removed directly, so the task can always be deleted. A stale registration is pruned by git later. Paths outside that directory (the task's cwd comes from its stored record) keep git's error and are never deleted by hand. Fixes #969. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 3 +- crates/ide-agent/src/worktree.rs | 55 +++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 4f61c8e0e..cabe079ea 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -823,7 +823,8 @@ impl AgentManager { // A copy of the task (a fork) may work in the same worktree. let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { - blocking(move || worktree::remove(&root, &cwd)).await?; + let worktrees = self.shared.paths.worktrees_dir.clone(); + blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?; } self.tasks.write().remove(id); *task.deleted.lock() = true; diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs index 6b261b3ac..b0e2f62fa 100644 --- a/crates/ide-agent/src/worktree.rs +++ b/crates/ide-agent/src/worktree.rs @@ -48,8 +48,24 @@ pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, bas Ok(Created { path, branch, base, base_commit }) } -pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { - repo_at(project_root)?.remove_worktree(path, true) +/// `git worktree remove --force -- `. When git no longer recognizes the +/// worktree (a pruned registration, a moved repository, a deleted `.git` file) +/// it fails with "is not a working tree". A folder under `worktrees_dir` is still +/// ours, so it is removed directly to keep the task deletable (a stale +/// registration prunes later); anything else keeps git's error. +pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) -> IdeResult<()> { + let repo = repo_at(project_root)?; + let err = match repo.remove_worktree(path, true) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + if !path.exists() { + return Ok(()); + } + if !path.starts_with(worktrees_dir) || path == worktrees_dir { + return Err(err); + } + std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string())) } /// Files under `cwd` that differ from `base` (untracked files included), with line counts. @@ -153,7 +169,7 @@ mod tests { assert!(snapshot(&dir).unwrap().is_empty()); assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None); assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err()); - assert!(remove(&dir, &dir).is_err()); + assert!(remove(&dir, tmp.path(), &dir).is_err()); } fn git(dir: &Path, args: &[&str]) { @@ -202,7 +218,38 @@ mod tests { // A second task with the same title gets its own worktree. assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); - remove(&repo, &created.path).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); assert!(!created.path.exists()); } + + #[test] + fn a_broken_worktree_registration_still_leaves_a_deletable_folder() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + // A pruned registration (the repository moved, `git worktree prune` ran). + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + let registration = repo.join(".git/worktrees").join(created.path.file_name().unwrap()); + std::fs::remove_dir_all(registration).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A deleted `.git` file in the worktree itself. + let created = create(&repo, &tmp.path().join("wt"), "Edit B", None).unwrap(); + std::fs::remove_file(created.path.join(".git")).unwrap(); + remove(&repo, &tmp.path().join("wt"), &created.path).unwrap(); + assert!(!created.path.exists()); + + // A folder outside the worktrees directory is never deleted by hand. + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("keep.txt"), "x\n").unwrap(); + assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err()); + assert!(elsewhere.join("keep.txt").exists()); + } }