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
View File
@@ -216,6 +216,7 @@ mod tests {
gateway_project: None,
status_push_enabled: true,
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
}
}
+1
View File
@@ -255,6 +255,7 @@ mod tests {
gateway_project: None,
status_push_enabled: true,
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
}
}
+1
View File
@@ -13,6 +13,7 @@ pub use create::install_pre_commit_hook;
pub use git::migrate_slug_paths;
pub(crate) use git::resolve_base_branch;
pub use remove::remove_worktree_by_story_id;
pub(crate) use sweep::worktree_should_be_swept;
#[derive(Debug, Clone)]
/// Details about a newly created worktree: path, branch, and base branch.
+57 -3
View File
@@ -7,6 +7,11 @@ use super::git::{branch_name, remove_worktree_sync, resolve_base_branch};
use super::{WorktreeInfo, worktree_path};
/// Remove a git worktree and its branch.
///
/// Story 1199: measures the worktree's `target/` size before removal (once
/// removed, there's nothing left to measure) and logs bytes reclaimed on
/// success, so terminal-story worktree cleanup reports its disk impact the
/// same way the on-demand `gc` pass does.
pub async fn remove_worktree(
project_root: &Path,
info: &WorktreeInfo,
@@ -14,13 +19,30 @@ pub async fn remove_worktree(
) -> Result<(), String> {
run_teardown_commands(&info.path, config).await?;
let target_path = info.path.join("target");
let target_bytes = tokio::task::spawn_blocking(move || {
crate::service::disk_watch::io::dir_size_bytes(&target_path)
})
.await
.unwrap_or(0);
let root = project_root.to_path_buf();
let wt_path = info.path.clone();
let branch = info.branch.clone();
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
let result =
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?;
if result.is_ok() && target_bytes > 0 {
crate::slog!(
"[worktree-remove] Reclaimed {target_bytes} bytes from '{}' target/ on removal",
info.path.display()
);
}
result
}
/// Remove a git worktree by story ID, deriving the path and branch deterministically.
@@ -91,6 +113,7 @@ mod tests {
gateway_project: None,
status_push_enabled: true,
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
}
}
@@ -205,4 +228,35 @@ mod tests {
.unwrap();
assert!(!path.exists());
}
/// Story 1199 AC4: removal reclaims a populated `target/` dir along with
/// the rest of the worktree.
#[tokio::test]
async fn remove_worktree_reclaims_populated_target_dir() {
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 info = super::super::create::create_worktree(
&project_root,
"90_reclaim_target",
&empty_config(),
3001,
)
.await
.unwrap();
let target_dir = info.path.join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 1024]).unwrap();
assert!(target_dir.exists());
remove_worktree(&project_root, &info, &empty_config())
.await
.unwrap();
assert!(!info.path.exists(), "whole worktree must be gone");
assert!(!target_dir.exists(), "target/ must be reclaimed");
}
}
+1
View File
@@ -137,6 +137,7 @@ mod tests {
gateway_project: None,
status_push_enabled: true,
merge_failure_block_threshold: 3,
gc_min_free_gb: 0,
}
}