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

@@ -206,27 +206,28 @@ fn flush_pending(
} }
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than /// Scan `work/5_done/` and move any `.md` files whose mtime is older than
/// `done_retention` to `work/6_archived/`. /// `done_retention` to `work/6_archived/`. After each successful promotion,
/// removes the associated git worktree (if any) via [`crate::worktree::prune_worktree_sync`].
///
/// Also scans `work/6_archived/` for stories that still have a live worktree
/// and removes them (catches items that were archived before this sweep was
/// added).
///
/// Worktree removal failures are logged but never block the file move or other
/// cleanup work.
/// ///
/// Called periodically from the watcher thread. File moves will trigger normal /// Called periodically from the watcher thread. File moves will trigger normal
/// watcher events, which `flush_pending` will commit and broadcast. /// watcher events, which `flush_pending` will commit and broadcast.
fn sweep_done_to_archived(work_dir: &Path, done_retention: Duration) { fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Duration) {
// ── Part 1: promote old items from 5_done/ → 6_archived/ ───────────────
let done_dir = work_dir.join("5_done"); let done_dir = work_dir.join("5_done");
if !done_dir.exists() { if done_dir.exists() {
return;
}
let entries = match std::fs::read_dir(&done_dir) {
Ok(e) => e,
Err(e) => {
slog!("[watcher] sweep: failed to read 5_done/: {e}");
return;
}
};
let archived_dir = work_dir.join("6_archived"); let archived_dir = work_dir.join("6_archived");
match std::fs::read_dir(&done_dir) {
Err(e) => slog!("[watcher] sweep: failed to read 5_done/: {e}"),
Ok(entries) => {
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().is_none_or(|e| e != "md") { if path.extension().is_none_or(|e| e != "md") {
@@ -255,6 +256,14 @@ fn sweep_done_to_archived(work_dir: &Path, done_retention: Duration) {
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or("unknown"); .unwrap_or("unknown");
slog!("[watcher] sweep: promoted {item_id} → 6_archived/"); slog!("[watcher] sweep: promoted {item_id} → 6_archived/");
// Prune the worktree for this story (best effort).
if let Err(e) =
crate::worktree::prune_worktree_sync(git_root, item_id)
{
slog!(
"[watcher] sweep: worktree prune failed for {item_id}: {e}"
);
}
} }
Err(e) => { Err(e) => {
slog!("[watcher] sweep: failed to move {}: {e}", path.display()); slog!("[watcher] sweep: failed to move {}: {e}", path.display());
@@ -263,6 +272,27 @@ fn sweep_done_to_archived(work_dir: &Path, done_retention: Duration) {
} }
} }
} }
}
}
// ── Part 2: prune stale worktrees for items already in 6_archived/ ──────
let archived_dir = work_dir.join("6_archived");
if archived_dir.exists()
&& let Ok(entries) = std::fs::read_dir(&archived_dir)
{
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "md") {
continue;
}
if let Some(item_id) = path.file_stem().and_then(|s| s.to_str())
&& let Err(e) = crate::worktree::prune_worktree_sync(git_root, item_id)
{
slog!("[watcher] sweep: worktree prune failed for {item_id}: {e}");
}
}
}
}
/// Start the filesystem watcher on a dedicated OS thread. /// Start the filesystem watcher on a dedicated OS thread.
/// ///
@@ -410,7 +440,7 @@ pub fn start_watcher(
let now = Instant::now(); let now = Instant::now();
if now.duration_since(last_sweep) >= sweep_interval { if now.duration_since(last_sweep) >= sweep_interval {
last_sweep = now; last_sweep = now;
sweep_done_to_archived(&work_dir, done_retention); sweep_done_to_archived(&work_dir, &git_root, done_retention);
} }
} }
} }
@@ -758,7 +788,8 @@ mod tests {
.unwrap(); .unwrap();
let retention = Duration::from_secs(4 * 60 * 60); let retention = Duration::from_secs(4 * 60 * 60);
sweep_done_to_archived(&work_dir, retention); // tmp.path() has no worktrees dir — prune_worktree_sync is a no-op.
sweep_done_to_archived(&work_dir, tmp.path(), retention);
assert!(!story_path.exists(), "old item should be moved out of 5_done/"); assert!(!story_path.exists(), "old item should be moved out of 5_done/");
assert!( assert!(
@@ -779,7 +810,7 @@ mod tests {
fs::write(&story_path, "---\nname: new\n---\n").unwrap(); fs::write(&story_path, "---\nname: new\n---\n").unwrap();
let retention = Duration::from_secs(4 * 60 * 60); let retention = Duration::from_secs(4 * 60 * 60);
sweep_done_to_archived(&work_dir, retention); sweep_done_to_archived(&work_dir, tmp.path(), retention);
assert!(story_path.exists(), "recent item should remain in 5_done/"); assert!(story_path.exists(), "recent item should remain in 5_done/");
} }
@@ -802,7 +833,7 @@ mod tests {
.unwrap(); .unwrap();
// With a 1-minute retention, the 2-minute-old file should be swept. // With a 1-minute retention, the 2-minute-old file should be swept.
sweep_done_to_archived(&work_dir, Duration::from_secs(60)); sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60));
assert!( assert!(
!story_path.exists(), !story_path.exists(),
@@ -831,11 +862,142 @@ mod tests {
.unwrap(); .unwrap();
// With a 1-minute retention, the 30-second-old file should stay. // With a 1-minute retention, the 30-second-old file should stay.
sweep_done_to_archived(&work_dir, Duration::from_secs(60)); sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60));
assert!( assert!(
story_path.exists(), story_path.exists(),
"item younger than custom retention should remain" "item younger than custom retention should remain"
); );
} }
// ── sweep worktree pruning ─────────────────────────────────────────────
/// Helper: create a real git worktree at `wt_path` on a new branch.
fn create_git_worktree(git_root: &std::path::Path, wt_path: &std::path::Path, branch: &str) {
use std::process::Command;
// Create the branch first (ignore errors if it already exists).
let _ = Command::new("git")
.args(["branch", branch])
.current_dir(git_root)
.output();
Command::new("git")
.args(["worktree", "add", &wt_path.to_string_lossy(), branch])
.current_dir(git_root)
.output()
.expect("git worktree add");
}
#[test]
fn sweep_prunes_worktree_when_story_promoted_to_archived() {
let tmp = TempDir::new().unwrap();
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".story_kit").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();
let story_id = "60_story_prune_on_promote";
let story_path = done_dir.join(format!("{story_id}.md"));
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let past = SystemTime::now()
.checked_sub(Duration::from_secs(5 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
// Create a real git worktree for this story.
let wt_path = crate::worktree::worktree_path(&git_root, story_id);
fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
create_git_worktree(&git_root, &wt_path, &format!("feature/story-{story_id}"));
assert!(wt_path.exists(), "worktree must exist before sweep");
let retention = Duration::from_secs(4 * 60 * 60);
sweep_done_to_archived(&work_dir, &git_root, retention);
// Story must be archived.
assert!(
!story_path.exists(),
"story should be moved out of 5_done/"
);
assert!(
work_dir.join("6_archived").join(format!("{story_id}.md")).exists(),
"story should appear in 6_archived/"
);
// Worktree must be removed.
assert!(!wt_path.exists(), "worktree should be removed after archiving");
}
#[test]
fn sweep_prunes_worktrees_for_already_archived_stories() {
let tmp = TempDir::new().unwrap();
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".story_kit").join("work");
let archived_dir = work_dir.join("6_archived");
fs::create_dir_all(&archived_dir).unwrap();
// Story is already in 6_archived.
let story_id = "61_story_stale_worktree";
let story_path = archived_dir.join(format!("{story_id}.md"));
fs::write(&story_path, "---\nname: stale\n---\n").unwrap();
// Create a real git worktree that was never cleaned up.
let wt_path = crate::worktree::worktree_path(&git_root, story_id);
fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
create_git_worktree(&git_root, &wt_path, &format!("feature/story-{story_id}"));
assert!(wt_path.exists(), "stale worktree must exist before sweep");
// 5_done/ is empty — only Part 2 runs.
fs::create_dir_all(work_dir.join("5_done")).unwrap();
let retention = Duration::from_secs(4 * 60 * 60);
sweep_done_to_archived(&work_dir, &git_root, retention);
// Stale worktree should be pruned.
assert!(!wt_path.exists(), "stale worktree should be pruned by sweep");
// Story file must remain untouched.
assert!(story_path.exists(), "archived story file must not be removed");
}
#[test]
fn sweep_archives_story_even_when_worktree_removal_fails() {
// Use a git repo so prune_worktree_sync can attempt removal,
// but the fake directory is not a registered git worktree so
// `git worktree remove` will fail — the story must still be archived.
let tmp = TempDir::new().unwrap();
let git_root = tmp.path().to_path_buf();
init_git_repo(&git_root);
let work_dir = git_root.join(".story_kit").join("work");
let done_dir = work_dir.join("5_done");
fs::create_dir_all(&done_dir).unwrap();
let story_id = "62_story_fake_worktree";
let story_path = done_dir.join(format!("{story_id}.md"));
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let past = SystemTime::now()
.checked_sub(Duration::from_secs(5 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
// Create a plain directory at the expected worktree path — not a real
// git worktree, so `git worktree remove` will fail.
let wt_path = crate::worktree::worktree_path(&git_root, story_id);
fs::create_dir_all(&wt_path).unwrap();
let retention = Duration::from_secs(4 * 60 * 60);
sweep_done_to_archived(&work_dir, &git_root, retention);
// Story must still be archived despite the worktree removal failure.
assert!(
!story_path.exists(),
"story should be archived even when worktree removal fails"
);
assert!(
work_dir.join("6_archived").join(format!("{story_id}.md")).exists(),
"story should appear in 6_archived/ despite worktree removal failure"
);
}
} }

View File

@@ -166,6 +166,21 @@ fn configure_sparse_checkout(_wt_path: &Path) -> Result<(), String> {
Ok(()) 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. /// Remove a git worktree and its branch.
pub async fn remove_worktree( pub async fn remove_worktree(
project_root: &Path, project_root: &Path,
@@ -653,6 +668,33 @@ mod tests {
assert!(result.is_ok(), "Expected removal to succeed: {:?}", result.err()); 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] #[tokio::test]
async fn create_worktree_succeeds_despite_setup_failure() { async fn create_worktree_succeeds_despite_setup_failure() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();