fix(core/housekeeping): prune merged worktree branches (fixes #516)

This commit is contained in:
James Peter
2026-01-13 22:45:40 +10:00
parent 8d253e3f46
commit 44ca2f968c
2 changed files with 137 additions and 0 deletions
+23
View File
@@ -416,6 +416,25 @@ async fn cli_main(code_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()>
interactive.finalize_defaults();
interactive.demo_developer_message = demo_developer_message.clone();
// The TUI already runs housekeeping. For headless `exec` sessions, kick off
// housekeeping early so stale worktrees/branches don't accumulate.
let housekeeping_handle = match &subcommand {
Some(Subcommand::Exec(_)) | Some(Subcommand::Auto(_)) => {
match code_core::config::find_code_home() {
Ok(code_home) => Some(std::thread::spawn(move || {
if let Err(err) = code_core::run_housekeeping_if_due(&code_home) {
tracing::warn!("code home housekeeping failed: {err}");
}
})),
Err(err) => {
tracing::warn!("failed to resolve code home for housekeeping: {err}");
None
}
}
}
_ => None,
};
match subcommand {
None => {
prepend_config_flags(
@@ -612,6 +631,10 @@ async fn cli_main(code_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()>
}
}
if let Some(handle) = housekeeping_handle {
let _ = handle.join();
}
Ok(())
}
+114
View File
@@ -306,6 +306,9 @@ fn cleanup_worktrees(
continue;
}
let branch_name = branch_entry.file_name().to_string_lossy().to_string();
let repo_root = detect_repo_root(&branch_path);
let canonical = canonicalize_or_original(&branch_path);
if active.contains(&canonical) || active.contains(&branch_path) {
stats.skipped_active += 1;
@@ -352,6 +355,13 @@ fn cleanup_worktrees(
Ok(()) => {
git_worktree::remove_branch_metadata(&branch_path);
purge_session_registry(&working_root.join("_session"), &branch_path);
if let Some(repo_root) = repo_root.as_deref() {
if should_prune_worktree_branch(&branch_name) {
run_git_branch_delete_if_merged(repo_root, &branch_name);
}
}
stats.removed_worktrees += 1;
stats.removed_files += dir_stats.files;
stats.reclaimed_bytes += dir_stats.bytes;
@@ -374,6 +384,42 @@ fn cleanup_worktrees(
Ok(Some(stats))
}
fn should_prune_worktree_branch(branch_name: &str) -> bool {
if branch_name == "main" || branch_name == "master" {
return false;
}
branch_name.starts_with("code-") || branch_name == "auto-review"
}
fn run_git_branch_delete_if_merged(repo_root: &Path, branch_name: &str) {
if !repo_root.exists() {
return;
}
let output = std::process::Command::new("git")
.current_dir(repo_root)
.args(["branch", "-d", branch_name])
.output();
match output {
Ok(result) => {
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
debug!(
"git branch -d reported error for {:?} in {:?}: {}",
branch_name,
repo_root,
stderr.trim()
);
}
}
Err(err) => {
debug!("git branch -d failed for {:?} in {:?}: {err}", branch_name, repo_root);
}
}
}
fn run_git_worktree_remove(worktree_path: &Path) {
let Some(repo_root) = detect_repo_root(worktree_path) else {
return;
@@ -755,6 +801,7 @@ fn matches_ignore_case(value: &str, options: &[&str]) -> bool {
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use time::macros::datetime;
@@ -837,6 +884,73 @@ mod tests {
assert!(worktree_path.exists());
}
#[test]
fn prunes_merged_code_branches_after_worktree_removal() {
let temp = TempDir::new().unwrap();
let code_home = temp.path();
let repo_dir = temp.path().join("repo");
fs::create_dir_all(&repo_dir).unwrap();
run_git(&repo_dir, ["init"]).unwrap();
fs::write(repo_dir.join("README.md"), b"hello").unwrap();
run_git(&repo_dir, ["add", "."]).unwrap();
run_git(
&repo_dir,
[
"-c",
"user.name=code",
"-c",
"[email protected]",
"commit",
"-m",
"init",
],
)
.unwrap();
let worktree_path = code_home.join("working/repo/branches/code-branch-test");
fs::create_dir_all(worktree_path.parent().unwrap()).unwrap();
run_git(&repo_dir, ["worktree", "add", "-b", "code-branch-test", worktree_path.to_str().unwrap()])
.unwrap();
assert!(branch_exists(&repo_dir, "code-branch-test"));
let config = HousekeepingConfig {
session_retention_days: None,
worktree_retention_days: Some(0),
min_interval_hours: 1,
disabled: false,
};
let now = datetime!(2025-10-10 12:00:00 UTC);
let outcome = perform_housekeeping(code_home, now, &config).unwrap();
assert_eq!(outcome.worktrees_removed, 1);
assert!(!worktree_path.exists());
assert!(!branch_exists(&repo_dir, "code-branch-test"));
}
fn run_git(repo_root: &Path, args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>) -> io::Result<()> {
let output = std::process::Command::new("git")
.current_dir(repo_root)
.args(args)
.output()?;
if output.status.success() {
Ok(())
} else {
Err(io::Error::other(String::from_utf8_lossy(&output.stderr).trim().to_string()))
}
}
fn branch_exists(repo_root: &Path, branch_name: &str) -> bool {
let output = std::process::Command::new("git")
.current_dir(repo_root)
.args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{branch_name}")])
.status();
output.map(|status| status.success()).unwrap_or(false)
}
#[cfg(target_os = "linux")]
#[test]
fn drops_registry_entries_for_dead_pids() {