feat(story-200): auto-prune worktrees when stories are archived

- Add `prune_worktree_sync` to worktree.rs: removes a story's worktree
  if it exists, delegating to `remove_worktree_sync` (best-effort,
  failures logged internally)
- Update `sweep_done_to_archived` to accept `git_root` and call
  `prune_worktree_sync` after promoting a story from 5_done to 6_archived
- Add Part 2 to the sweep: scan 6_archived and prune any stale worktrees
  for stories already there (catches items archived before this feature)
- All worktree removal failures are logged but never block file moves
- Add 5 new tests: prune noop, prune real worktree, sweep-on-promote,
  sweep-stale-archived, sweep-not-blocked-by-removal-failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-26 14:58:52 +00:00
parent 4e535dff18
commit 40d96008c9
11 changed files with 252 additions and 48 deletions

View File

@@ -166,6 +166,21 @@ fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> {
Ok(())
}
/// Remove the git worktree for a story if it exists, deriving the path and
/// branch name deterministically from `project_root` and `story_id`.
///
/// Returns `Ok(())` if the worktree was removed or did not exist.
/// Removal is best-effort: `remove_worktree_sync` logs failures internally
/// but always returns `Ok`.
pub fn prune_worktree_sync(project_root: &Path, story_id: &str) -> Result<(), String> {
let wt_path = worktree_path(project_root, story_id);
if !wt_path.exists() {
return Ok(());
}
let branch = branch_name(story_id);
remove_worktree_sync(project_root, &wt_path, &branch)
}
/// Remove a git worktree and its branch.
pub async fn remove_worktree(
project_root: &Path,
@@ -653,6 +668,33 @@ mod tests {
assert!(result.is_ok(), "Expected removal to succeed: {:?}", result.err());
}
// ── prune_worktree_sync ──────────────────────────────────────────────────
#[test]
fn prune_worktree_sync_noop_when_no_worktree_dir() {
let tmp = TempDir::new().unwrap();
// No worktree directory exists — must return Ok without touching git.
let result = prune_worktree_sync(tmp.path(), "42_story_nonexistent");
assert!(result.is_ok(), "Expected Ok when worktree dir absent: {:?}", result.err());
}
#[test]
fn prune_worktree_sync_removes_real_worktree() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
let story_id = "55_story_prune_test";
let wt_path = worktree_path(&project_root, story_id);
create_worktree_sync(&project_root, &wt_path, &format!("feature/story-{story_id}")).unwrap();
assert!(wt_path.exists(), "worktree dir should exist before prune");
let result = prune_worktree_sync(&project_root, story_id);
assert!(result.is_ok(), "prune_worktree_sync must return Ok: {:?}", result.err());
assert!(!wt_path.exists(), "worktree dir should be gone after prune");
}
#[tokio::test]
async fn create_worktree_succeeds_despite_setup_failure() {
let tmp = TempDir::new().unwrap();