Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 221 additions & 7 deletions src-tauri/src/work_task/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,45 @@ pub(crate) const RETRY_THE_CLEANUP: &str = "Remove them, then retry the cleanup.
/// The probe every worktree-removal gate shares: does this checkout still hold
/// work (tracked edits or files git has never seen)?
///
/// FAILS CLOSED. Exactly one reason for git being unable to answer is safe to
/// read as "clean" — the checkout is already off disk, so there is nothing left
/// to lose, and the removal paths handle a missing directory on their own. Any
/// OTHER failure (a corrupt index, a permission error, a transient git fault)
/// means we could not prove the directory is safe to destroy, and the operation
/// waiting on this answer is `git worktree remove --force`. Guessing "clean"
/// FAILS CLOSED. Only two reasons for git being unable to answer are safe to
/// read as "clean": the checkout is already off disk, or git removed its
/// registration and contents but left a readable, strictly empty directory
/// behind. Any entry in that shell (ignored and hidden files included), a
/// corrupt index, a permission error, or another transient filesystem failure
/// means we could not prove the directory is safe to destroy. The operation
/// waiting on this answer is `git worktree remove --force`, so guessing "clean"
/// there trades a recoverable stall for unrecoverable files.
///
/// The `.git` marker is checked FIRST, and not as an optimization: `has_changes`
/// runs `git status` with the path as its working directory, and git walks UP
/// from there. A shell with no marker left is not a checkout git can speak for,
/// so whatever it answers is about the repository that ENCLOSES the shell — the
/// project itself whenever the folder's worktree root is a path inside it. That
/// answer is a clean `Ok(false)` or a dirty `Ok(true)` about somebody else's
/// files, and neither is this path's; only the directory probe below is.
async fn path_holds_uncommitted(path: &str) -> bool {
match std::fs::symlink_metadata(Path::new(path).join(".git")) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return shell_holds_entries(path),
// Could not even look at the marker — no proof of anything. Fail closed.
Err(_) => return true,
}
match task_git::has_changes(path).await {
Ok(dirty) => dirty,
Err(_) => Path::new(path).exists(),
Err(_) => shell_holds_entries(path),
}
}

/// The one thing left to ask about a directory git has stopped speaking for: is
/// it strictly empty? Anything else — an entry of any kind, or a failure to read
/// the directory at all — is "holds work", because a `--force` removal is what
/// waits on the answer. Only a path that is gone reads as nothing to lose.
fn shell_holds_entries(path: &str) -> bool {
match std::fs::read_dir(path) {
// `Some(Err(_))` is deliberately still "holds work": even learning
// whether an entry exists has to succeed before removal is safe.
Ok(mut entries) => entries.next().is_some(),
Err(e) => e.kind() != std::io::ErrorKind::NotFound,
}
}

Expand Down Expand Up @@ -10131,6 +10159,192 @@ mod tests {
assert!(!f.engine.index.lock().await.contains_key(ZOMBIE), "retired");
}

/// Git for Windows can finish the destructive part of `worktree remove`
/// (registration and checkout contents) but fail to remove the now-empty
/// directory. That first pass leaves the task flagged for cleanup; its retry
/// must recognize the harmless shell, finish the branch/DB cleanup, and not
/// report files that do not exist.
#[tokio::test]
async fn worktree_cleanup_retry_converges_an_empty_detached_shell() {
let f = delivery_fixture(FakeForge::default()).await;
let worktree = f.worktree.to_str().expect("utf-8 worktree");
git_run(&f.root, &["worktree", "remove", "--force", worktree]);
std::fs::create_dir(&f.worktree).expect("empty shell");
work_task_service::set_cleanup_state(
&f.engine.db.conn,
f.task_id,
true,
Some("the first removal stopped after git detached it".into()),
)
.await
.expect("flag failed cleanup");

f.engine
.cleanup_task(f.task_id)
.await
.expect("retry cleanup");

let task = row(&f.engine, f.task_id).await;
assert_eq!(task.cleanup_state, None, "the retry is fully settled");
assert_eq!(
task.worktree_folder_id, None,
"folder bookkeeping converged"
);
assert!(!f.worktree.exists(), "the empty shell is gone");
assert!(
task_git::rev_parse(f.root.to_str().unwrap(), "refs/heads/task/7")
.await
.is_err(),
"the requested work branch goes too"
);
}

/// A missing `.git` file alone is not proof that the directory is a harmless
/// post-removal shell. Files may have appeared after the partial teardown,
/// and none of them is recoverable through git, so the retry must preserve
/// both the sentinel and the branch.
#[tokio::test]
async fn worktree_cleanup_retry_preserves_a_file_in_a_detached_shell() {
let f = delivery_fixture(FakeForge::default()).await;
let worktree = f.worktree.to_str().expect("utf-8 worktree");
git_run(&f.root, &["worktree", "remove", "--force", worktree]);
std::fs::create_dir(&f.worktree).expect("empty shell");
std::fs::write(f.worktree.join("sentinel.txt"), "not in git\n").expect("sentinel");

let err = f
.engine
.cleanup_task(f.task_id)
.await
.expect_err("a non-empty shell is not removable");

assert!(err.holds_work, "the retry is refused as a data-safety gate");
assert_eq!(
std::fs::read_to_string(f.worktree.join("sentinel.txt")).expect("read sentinel"),
"not in git\n"
);
let task = row(&f.engine, f.task_id).await;
assert!(
task.worktree_folder_id.is_some(),
"the retry remains available"
);
assert!(
task_git::rev_parse(f.root.to_str().unwrap(), "refs/heads/task/7")
.await
.is_ok(),
"the branch survives with the sentinel"
);
}

/// A repository with `layout` applied to it, for the probe tests below:
/// returns `(tempdir, repo path, worktree path)`. The two tests that pass
/// `"sibling"` reproduce the DEFAULT worktree layout — beside the project,
/// with no repository of the fixture's own above it, which is the layout in
/// which a shell has no enclosing repository for `git status` to answer
/// about instead. (Nothing here can rule out a repository ABOVE the system
/// temp directory; in that environment the sibling pair still asserts the
/// right answers, but it is `an_empty_shell_inside_a_dirty_project_reads_as_clean`
/// — which builds its own enclosing repository — that pins the regression
/// unconditionally.)
fn probe_repo(layout: &str) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("repo");
std::fs::create_dir(&repo).expect("mkdir");
git_run(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").expect("write");
git_run(&repo, &["add", "-A"]);
git_run(&repo, &["commit", "-q", "-m", "base"]);
let worktree = match layout {
"sibling" => dir.path().join("wt"),
_ => repo.join("wt"),
};
(dir, repo, worktree)
}

/// THE probe behind issue #642's dead-end retry, exercised where the bug
/// actually happens. `has_changes` runs `git status` from the path itself,
/// so a shell in the default layout — beside the project, no repository
/// above it — makes git error, and reading that error as "dirty" is what
/// made the retry offer the same refusal forever. An empty shell has, by
/// definition, nothing a `--force` removal could take.
#[tokio::test]
async fn an_empty_shell_beside_a_project_reads_as_clean() {
let (_dir, _repo, worktree) = probe_repo("sibling");
std::fs::create_dir(&worktree).expect("empty shell");

assert!(
!path_holds_uncommitted(worktree.to_str().expect("utf-8")).await,
"an empty post-removal shell holds nothing"
);
}

/// The other half of the same answer: outside a repository git errors on a
/// shell whatever is in it, so emptiness is the ONLY thing separating a
/// harmless leftover from files nothing can give back.
#[tokio::test]
async fn a_file_in_a_shell_beside_a_project_still_reads_as_dirty() {
let (_dir, _repo, worktree) = probe_repo("sibling");
std::fs::create_dir(&worktree).expect("shell");
std::fs::write(worktree.join("sentinel.txt"), "not in git\n").expect("sentinel");

assert!(
path_holds_uncommitted(worktree.to_str().expect("utf-8")).await,
"a shell with an entry in it is not removable"
);
}

/// A worktree root configured INSIDE the project (a relative
/// `worktree_root`, which the setting takes at face value) puts the shell
/// under the project's own `.git`. `git status` then answers happily — about
/// the PROJECT — so a project with a stray file of its own would answer
/// "dirty" for a shell holding nothing, and #642's retry would still never
/// converge. Without a `.git` marker the path is not a checkout git speaks
/// for, and its answer must not be read as this path's.
///
/// The project's dirt is an edit to a TRACKED file, not a stray untracked
/// one: `has_changes` spawns git through `crate::process`, which inherits
/// the real environment, so an untracked fixture is only as visible as the
/// developer's global `core.excludesFile` lets it be — and a fixture git
/// silently ignores would make this test pass against the very bug it
/// exists to pin. No ignore rule can hide a modified tracked file.
#[tokio::test]
async fn an_empty_shell_inside_a_dirty_project_reads_as_clean() {
let (_dir, repo, worktree) = probe_repo("nested");
std::fs::write(repo.join("a.txt"), "the project's own mess\n").expect("dirty project");
std::fs::create_dir(&worktree).expect("empty shell");

assert!(
!path_holds_uncommitted(worktree.to_str().expect("utf-8")).await,
"the project's dirt is not the shell's"
);
}

/// And the marker check must not short-circuit the case it is guarding: a
/// checkout that still HAS its `.git` is exactly what `git status` is for,
/// uncommitted work included. Tracked and modified for the reason above —
/// an untracked fixture would read as clean under a global ignore rule that
/// happens to match it, and fail this assertion on that developer's machine
/// alone.
#[tokio::test]
async fn an_intact_worktree_is_still_measured_by_git() {
let (_dir, repo, worktree) = probe_repo("sibling");
let worktree_path = worktree.to_str().expect("utf-8");
git_run(
&repo,
&["worktree", "add", "-q", "-b", "task/probe", worktree_path],
);
assert!(
!path_holds_uncommitted(worktree_path).await,
"a fresh checkout holds nothing"
);

std::fs::write(worktree.join("a.txt"), "unstaged\n").expect("edit");

assert!(
path_holds_uncommitted(worktree_path).await,
"an uncommitted edit in a live checkout is work"
);
}

/// The removal underneath is `worktree remove --force`, and a stop pressed
/// while the agent is editing is exactly when a checkout is dirty. Nothing
/// the user clicked named those files — the cancel dialog's checkbox offers
Expand Down
Loading
Loading