Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 100 additions & 11 deletions crates/flare-git-core/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,11 +541,13 @@ pub fn audit_orphans(
orphans
}

/// Delete orphaned worktrees: snapshot first, then remove.
/// Delete orphaned worktrees: snapshot first, then remove with retry.
///
/// Takes a list of worktree names (as returned by `audit_orphans`) and
/// snapshots each one before removing it. Returns the names actually
/// deleted.
/// snapshots each one before removing it. Removal uses `remove_worktree_dir`
/// which retries with exponential backoff on Windows, falls back to `cmd /c
/// rmdir`, and reports locking processes via handle64.exe when present.
/// Returns the names actually deleted.
pub fn gc_orphans(repo_root: &Path, names: &[String]) -> Vec<String> {
let mut deleted = Vec::new();
for name in names {
Expand All @@ -565,14 +567,8 @@ pub fn gc_orphans(repo_root: &Path, names: &[String]) -> Vec<String> {
);
continue;
}
// Remove the worktree directory
match std::fs::remove_dir_all(&worktree_path) {
Ok(()) => {
deleted.push(name.clone());
}
Err(e) => {
eprintln!("worktree: failed to remove orphan '{}': {}", name, e);
}
if remove_worktree_dir(&worktree_path, name) {
deleted.push(name.clone());
}
}
// Prune git's worktree metadata after removal
Expand All @@ -582,6 +578,62 @@ pub fn gc_orphans(repo_root: &Path, names: &[String]) -> Vec<String> {
deleted
}

/// Remove a worktree directory, with retry + Windows fallback.
///
/// On Windows, background processes (rust-analyzer, proc-macro-srv) may
/// hold file handles that block `remove_dir_all` with Permission denied.
/// Retries with exponential backoff, falls back to `cmd /c rmdir`, and
/// attempts to identify the locking process via handle64.exe when present.
fn remove_worktree_dir(path: &Path, name: &str) -> bool {
let delays_ms = [100u64, 200, 400, 800, 1600];
for delay in &delays_ms {
if std::fs::remove_dir_all(path).is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(*delay));
}
if std::fs::remove_dir_all(path).is_ok() {
return true;
}

#[cfg(windows)]
{
if std::process::Command::new("cmd")
.args(["/c", "rmdir", "/s", "/q", &path.to_string_lossy()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}

for handle_exe in &["handle64.exe", "handle.exe"] {
if let Ok(output) = std::process::Command::new(handle_exe)
.args(["-accepteula", "-nobanner", &path.to_string_lossy()])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
{
if output.status.success() {
let out = String::from_utf8_lossy(&output.stdout);
for line in out.lines().take(5) {
eprintln!("worktree: locking process for '{}': {}", name, line);
}
}
break;
}
}
}

eprintln!(
"worktree: failed to remove orphan '{}': Permission denied",
name
);
false
}

/// Recursive directory size in bytes.
fn dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
Expand All @@ -602,6 +654,43 @@ mod tests {
init_repo_with_branch("master")
}

#[test]
fn remove_worktree_dir_succeeds_immediately_when_unlocked() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("wt");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), b"hello").unwrap();
assert!(remove_worktree_dir(&dir, "test"));
assert!(!dir.exists());
}

#[test]
fn remove_worktree_dir_retries_past_a_transient_lock() {
// On Windows, an open file handle blocks remove_dir_all with
// Permission denied until released -- exactly item #302's failure
// mode (rust-analyzer holding a worktree file open). On Unix this
// doesn't block deletion at all, so the test still passes there,
// just without exercising the retry path meaningfully.
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("wt");
std::fs::create_dir_all(&dir).unwrap();
let locked_file = dir.join("locked.txt");
std::fs::write(&locked_file, b"hello").unwrap();

let file = std::fs::File::open(&locked_file).unwrap();
let releaser = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(250));
drop(file);
});

assert!(
remove_worktree_dir(&dir, "test"),
"must succeed once the lock clears, within the retry budget"
);
assert!(!dir.exists());
releaser.join().unwrap();
}

fn test_item(sequence_id: i64) -> Item {
Item {
id: "test-id".into(),
Expand Down
34 changes: 29 additions & 5 deletions scripts/cleanup-branches.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,35 @@ for b in "${!candidates[@]}"; do
fi

if [[ -n "$wt" ]]; then
if git worktree remove "$wt" 2>/dev/null; then
echo "removed worktree $wt"
else
echo "skip $b: worktree $wt has uncommitted changes" >&2
continue
# Retry worktree removal on Windows where file locks (rust-analyzer,
# proc-macro-srv) can transiently block deletion. Exponential backoff
# up to ~16s total before falling through.
wt_removed=0
delay=1
for attempt in 1 2 3 4 5; do
if git worktree remove "$wt" 2>/dev/null; then
echo "removed worktree $wt"
wt_removed=1
break
fi
# Distinguish "Permission denied" (file lock) from "dirty" (unsafe)
err=$(git worktree remove "$wt" 2>&1 1>/dev/null) || true
if [[ "$err" == *"dirty"* || "$err" == *"has untracked"* || "$err" == *"has uncommitted"* ]]; then
echo "skip $b: worktree $wt has uncommitted changes" >&2
break
fi
if ((attempt < 5)); then
sleep "$delay"
delay=$((delay * 2))
fi
done
if ((wt_removed == 0)); then
# Permission denied after retries — likely rust-analyzer locking
# files on Windows. Still prune the git metadata so the branch
# itself can be cleaned up. The leftover directory will be caught
# by gc_orphans (which has retry+rmdir fallback) on next audit.
echo "warn: worktree $wt could not be removed (likely file lock)" >&2
echo "warn: pruning git admin entry, dir will be cleaned later" >&2
fi
fi

Expand Down
Loading