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
68 changes: 63 additions & 5 deletions cli/worktree/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,58 @@ 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. 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<Record> {
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: None,
agent: None,
pid: None,
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<String>) -> 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),
path,
branch,
already_free: true,
removed: false,
warnings: Vec::new(),
}
}

fn release_locked(
pool: &Pool,
host: &dyn Host,
Expand All @@ -978,12 +1030,16 @@ fn release_locked(
expected: Option<&Record>,
force: bool,
) -> Result<Released, WorktreeError> {
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)) 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
Expand All @@ -996,7 +1052,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),
Expand Down
77 changes: 77 additions & 0 deletions cli/worktree/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,83 @@ 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_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();
Expand Down
Loading