huskies: merge 1199 story Orphaned build-dir GC: reclaim dead worktree targets without touching warm caches

This commit is contained in:
Huskies Agent
2026-07-17 20:57:21 +00:00
parent 2bd41d980c
commit 5b340e7b20
18 changed files with 565 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
mod squash;
pub(crate) use squash::run_squash_merge;
pub(crate) use squash::{merge_lock_is_free, run_squash_merge};
/// Typed outcome of a completed squash-merge operation.
///
+13
View File
@@ -17,6 +17,19 @@ use crate::config::ProjectConfig;
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
static MERGE_LOCK: Mutex<()> = Mutex::new(());
/// Returns `true` when no squash-merge is currently running, i.e. the merge
/// lock is free.
///
/// Used by the build-directory GC pass (story 1199) to decide whether
/// `.huskies/merge_workspace` is safe to reclaim. This is a best-effort,
/// momentary check — the lock is not held across the reclaim itself, so a
/// merge that starts immediately afterward can still race with GC. That's
/// acceptable: the GC pass tolerates races and skips on error rather than
/// failing the whole pass.
pub(crate) fn merge_lock_is_free() -> bool {
MERGE_LOCK.try_lock().is_ok()
}
/// Resolve the base branch for `project_root` from config, or auto-detect it.
fn resolve_base_branch(project_root: &Path) -> String {
let configured = crate::config::ProjectConfig::load(project_root)
@@ -157,6 +157,12 @@ pub(crate) async fn on_coding_transition(project_root: &Path, port: u16, story_i
}
/// Remove the worktree and feature branch for `story_id` after it reaches a terminal stage.
///
/// Story 1199: bytes-reclaimed accounting for the worktree's `target/` dir
/// happens one layer down, in `remove_worktree` — the single choke point
/// shared by this subscriber, the `remove_worktree` MCP tool, and
/// `worktree::cleanup`/`sweep`. Logging it there (once) avoids a duplicate
/// log line here.
pub(crate) async fn on_terminal_transition(project_root: &Path, story_id: &str) {
let config = match crate::config::ProjectConfig::load(project_root) {
Ok(c) => c,
@@ -270,6 +276,30 @@ mod tests {
);
}
/// Story 1199 AC4: on_terminal_transition reclaims the worktree's target/
/// dir (and everything else in the worktree) when removing it.
#[tokio::test]
async fn terminal_transition_reclaims_target_dir() {
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_reclaim_target";
on_coding_transition(&root, 3001, story_id).await;
let wt_path = crate::worktree::worktree_path(&root, story_id);
let target_dir = wt_path.join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 512]).unwrap();
assert!(target_dir.exists(), "target/ must exist before cleanup");
on_terminal_transition(&root, story_id).await;
assert!(
!target_dir.exists(),
"target/ dir must be reclaimed when the worktree is removed"
);
assert!(!wt_path.exists());
}
/// AC2: on_terminal_transition is a no-op (non-fatal) when no worktree exists.
#[tokio::test]
async fn terminal_transition_noop_when_no_worktree() {