From 0ee334e903766d53b26f77a472fc114dc7105ef5 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 12:06:10 +0100 Subject: [PATCH 1/2] worktree: release frees slots with a missing lease record or directory (closes #9755) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/worktree/src/ops.rs | 58 +++++++++++++++++++++++++++++++++++---- cli/worktree/src/tests.rs | 39 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/cli/worktree/src/ops.rs b/cli/worktree/src/ops.rs index 3783458758..dca55fae6f 100644 --- a/cli/worktree/src/ops.rs +++ b/cli/worktree/src/ops.rs @@ -970,6 +970,48 @@ pub(crate) fn release_lease( release_locked(pool, host, who, lease.slot, Some(lease), false) } +/// A lease record for a slot checkout whose record file is missing, held by the releaser so the usual checks run. +fn orphan_record(pool: &Pool, slot: u32, who: &Who) -> Option { + let path = pool.slot_path(slot); + let checkout = pool + .checkouts() + .ok()? + .into_iter() + .find(|checkout| same_path(&checkout.path, &path))?; + Some(Record { + slot, + held: true, + branch: checkout.branch, + last_branch: None, + session: who.session.clone(), + agent: who.agent.clone(), + pid: Some(who.pid), + pid_start: None, + boot_id: None, + gone: false, + stopped_at: None, + unclaimed: false, + broken: false, + leased_at: 0, + last_used: 0, + lock_digest: None, + }) +} + +/// The outcome for a slot whose directory is already gone: only git's registration is left to drop. +fn gone_slot(pool: &Pool, slot: u32, path: PathBuf, branch: Option) -> Released { + pool.remove(slot); + let _ = git::run(&pool.main, &["worktree", "prune"]); + Released { + slot: Some(slot), + path, + branch, + already_free: true, + removed: false, + warnings: Vec::new(), + } +} + fn release_locked( pool: &Pool, host: &dyn Host, @@ -978,12 +1020,16 @@ fn release_locked( expected: Option<&Record>, force: bool, ) -> Result { - let record = pool.record(slot).ok_or_else(|| { - WorktreeError::usage( + let slot_path = pool.slot_path(slot); + let Some(record) = pool.record(slot).or_else(|| orphan_record(pool, slot, who)) else { + if !slot_path.exists() { + return Ok(gone_slot(pool, slot, slot_path, None)); + } + return Err(WorktreeError::usage( "not_a_slot", &format!("slot {slot} has no lease record; pass a path from dx worktree list"), - ) - })?; + )); + }; if expected.is_some_and(|lease| { record.held != lease.held || record.branch != lease.branch @@ -996,7 +1042,9 @@ fn release_locked( &format!("slot {slot} was leased again; run dx worktree list to see its holder"), )); } - let slot_path = pool.slot_path(slot); + if !slot_path.exists() { + return Ok(gone_slot(pool, slot, slot_path, record.branch)); + } if !record.held { return Ok(Released { slot: Some(slot), diff --git a/cli/worktree/src/tests.rs b/cli/worktree/src/tests.rs index 7cd17878c8..49eee9e748 100644 --- a/cli/worktree/src/tests.rs +++ b/cli/worktree/src/tests.rs @@ -834,6 +834,45 @@ fn release_frees_the_slot_and_the_next_add_reuses_it_without_leftovers() { ); } +#[test] +fn release_frees_a_slot_whose_lease_record_is_missing() { + let fixture = Fixture::new(); + let pool = fixture.pool(); + let host = FakeHost::new(); + let me = who("s1", 10); + let added = ops::add(&pool, &host, &me, &create("feature/a")).expect("add"); + git( + &added.path, + &["push", "--quiet", "-u", "origin", "feature/a"], + ); + pool.remove(added.slot); + let released = ops::release(&pool, &host, &me, &added.path, false).expect("release"); + assert_eq!(released.slot, Some(added.slot)); + assert!(!released.already_free); + assert_eq!(git(&added.path, &["branch", "--show-current"]), ""); + let row = ops::list(&pool, &host, false) + .expect("list") + .rows + .into_iter() + .find(|row| row.slot == Some(added.slot)) + .expect("row"); + assert_eq!(row.state, State::Free); +} + +#[test] +fn release_succeeds_when_the_slot_directory_is_already_gone() { + let fixture = Fixture::new(); + let pool = fixture.pool(); + let host = FakeHost::new(); + let me = who("s1", 10); + let added = ops::add(&pool, &host, &me, &create("feature/a")).expect("add"); + std::fs::remove_dir_all(&added.path).expect("remove slot"); + let released = ops::release(&pool, &host, &me, &added.path, false).expect("release"); + assert!(released.already_free); + assert!(pool.record(added.slot).is_none()); + ops::release(&pool, &host, &me, &added.path, false).expect("release again"); +} + #[test] fn release_refuses_uncommitted_changes_unless_forced() { let fixture = Fixture::new(); From 873da870f434ab45ff65ed54f66dae1cce28e7e8 Mon Sep 17 00:00:00 2001 From: Martin Kuntz Jacobsen Date: Mon, 9 Nov 2026 12:42:23 +0100 Subject: [PATCH 2/2] worktree: unlock a gone free slot before prune; orphan record has no holder Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Xut2hwESun1HL2eSVd1d5B --- cli/worktree/src/ops.rs | 22 ++++++++++++++++------ cli/worktree/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/cli/worktree/src/ops.rs b/cli/worktree/src/ops.rs index dca55fae6f..b1e04846b7 100644 --- a/cli/worktree/src/ops.rs +++ b/cli/worktree/src/ops.rs @@ -970,8 +970,9 @@ pub(crate) fn release_lease( release_locked(pool, host, who, lease.slot, Some(lease), false) } -/// A lease record for a slot checkout whose record file is missing, held by the releaser so the usual checks run. -fn orphan_record(pool: &Pool, slot: u32, who: &Who) -> Option { +/// A lease record for a slot checkout whose record file is missing. The holder is unknown, so the +/// releaser gets no ownership: it must pass the reclaimable checks (clean, pushed) or `--force`. +fn orphan_record(pool: &Pool, slot: u32) -> Option { let path = pool.slot_path(slot); let checkout = pool .checkouts() @@ -983,9 +984,9 @@ fn orphan_record(pool: &Pool, slot: u32, who: &Who) -> Option { held: true, branch: checkout.branch, last_branch: None, - session: who.session.clone(), - agent: who.agent.clone(), - pid: Some(who.pid), + session: None, + agent: None, + pid: None, pid_start: None, boot_id: None, gone: false, @@ -1001,6 +1002,15 @@ fn orphan_record(pool: &Pool, slot: u32, who: &Who) -> Option { /// The outcome for a slot whose directory is already gone: only git's registration is left to drop. fn gone_slot(pool: &Pool, slot: u32, path: PathBuf, branch: Option) -> Released { pool.remove(slot); + // A free slot is locked in git and prune skips locked worktrees, so unlock it first. + let _ = git::run( + &pool.main, + &[ + OsStr::new("worktree"), + OsStr::new("unlock"), + path.as_os_str(), + ], + ); let _ = git::run(&pool.main, &["worktree", "prune"]); Released { slot: Some(slot), @@ -1021,7 +1031,7 @@ fn release_locked( force: bool, ) -> Result { let slot_path = pool.slot_path(slot); - let Some(record) = pool.record(slot).or_else(|| orphan_record(pool, slot, who)) else { + let Some(record) = pool.record(slot).or_else(|| orphan_record(pool, slot)) else { if !slot_path.exists() { return Ok(gone_slot(pool, slot, slot_path, None)); } diff --git a/cli/worktree/src/tests.rs b/cli/worktree/src/tests.rs index 49eee9e748..c712127015 100644 --- a/cli/worktree/src/tests.rs +++ b/cli/worktree/src/tests.rs @@ -873,6 +873,44 @@ fn release_succeeds_when_the_slot_directory_is_already_gone() { ops::release(&pool, &host, &me, &added.path, false).expect("release again"); } +#[test] +fn release_of_a_free_slot_with_its_directory_gone_lets_add_reuse_the_slot() { + let fixture = Fixture::new(); + let pool = fixture.pool(); + let host = FakeHost::new(); + let me = who("s1", 10); + let added = ops::add(&pool, &host, &me, &create("feature/a")).expect("add"); + ops::release(&pool, &host, &me, &added.path, false).expect("free the slot"); + std::fs::remove_dir_all(&added.path).expect("remove slot"); + let released = ops::release(&pool, &host, &me, &added.path, false).expect("release"); + assert!(released.already_free); + let again = ops::add(&pool, &host, &me, &create("feature/b")).expect("add again"); + assert_eq!(again.slot, added.slot); + assert!(again.path.exists()); +} + +#[test] +fn release_of_a_record_less_slot_keeps_the_holder_check() { + let fixture = Fixture::new(); + let pool = fixture.pool(); + let host = FakeHost::new(); + let me = who("s1", 10); + let other = who("s2", 20); + let added = ops::add(&pool, &host, &me, &create("feature/a")).expect("add"); + pool.remove(added.slot); + std::fs::write(added.path.join("b.txt"), "new\n").expect("write"); + git(&added.path, &["add", "."]); + git(&added.path, &["commit", "--quiet", "-m", "local"]); + // The holder is unknown and the work is unpushed, so nobody releases it without --force. + let err = ops::release(&pool, &host, &other, &added.path, false).expect_err("refused"); + assert_eq!(err.code, "slot_leased"); + assert_eq!( + git(&added.path, &["branch", "--show-current"]), + "feature/a" + ); + ops::release(&pool, &host, &other, &added.path, true).expect("forced"); +} + #[test] fn release_refuses_uncommitted_changes_unless_forced() { let fixture = Fixture::new();