From 2340df2ee6efbeb158c6aafe858080eef237ed93 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Wed, 7 Oct 2026 00:31:22 -0400 Subject: [PATCH 1/2] Make Worktree acquisition preserve local Issue branch work (#485) --- src/branch.rs | 80 ++------ src/worktree.rs | 75 ++++++-- src/worktree/acquisition_tests.rs | 301 ++++++++++++++++++++++++++++++ tests/continuation.rs | 62 ++++++ 4 files changed, 437 insertions(+), 81 deletions(-) create mode 100644 src/worktree/acquisition_tests.rs diff --git a/src/branch.rs b/src/branch.rs index 8eb1af8ca..55fefe781 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -6,6 +6,7 @@ use anyhow::{Result, bail}; use crate::git::Git; use crate::github::{GitHub, PrState, PullRequest}; use crate::issue::IssueUrl; +use crate::worktree; pub enum Selection { /// No Issue branch has been used yet, or the highest-numbered one's PR is @@ -64,8 +65,9 @@ impl Selection { /// Pick the Issue branch for `issue` from the Issue branches on origin and /// their PRs in any state: the highest number seen on either decides. Fails -/// if a local copy of the chosen branch in the Launch directory differs from -/// origin's: the Run replaces it and deletes it at cleanup. +/// early if Worktree's advisory preflight refuses the sampled local head, +/// before the Run makes its Claim. Worktree independently checks again +/// during acquisition and owns normal cleanup of the acquired branch. pub fn select(launch: &Git, github: &GitHub, issue: &IssueUrl) -> Result { select_through(&GitHubAndOrigin { launch, github }, issue) } @@ -141,12 +143,7 @@ impl Reads for GitHubAndOrigin<'_> { } fn local_head(&self, branch: &str) -> Result> { - self.launch.run_optional(&[ - "rev-parse", - "--verify", - "--quiet", - &format!("refs/heads/{branch}"), - ]) + worktree::local_head(self.launch, branch) } } @@ -161,7 +158,11 @@ fn select_through(reads: &impl Reads, issue: &IssueUrl) -> Result { .chain(prs.iter().map(|(number, _)| *number)) .max(); let Some(highest) = highest else { - check_local_branch(reads, &first_branch, None)?; + worktree::check_local_branch( + &first_branch, + reads.local_head(&first_branch)?.as_deref(), + None, + )?; return Ok(Selection::Fresh { branch: first_branch, }); @@ -179,11 +180,15 @@ fn select_through(reads: &impl Reads, issue: &IssueUrl) -> Result { // A merged or closed PR uses its number up, even if its branch was deleted. (_, Some(pr)) if pr.state != PrState::Open => { let next = branch_name(issue, highest + 1); - check_local_branch(reads, &next, None)?; + worktree::check_local_branch(&next, reads.local_head(&next)?.as_deref(), None)?; Ok(Selection::Fresh { branch: next }) } (Some(origin_sha), pr) => { - check_local_branch(reads, &branch, Some(&origin_sha))?; + worktree::check_local_branch( + &branch, + reads.local_head(&branch)?.as_deref(), + Some(&origin_sha), + )?; Ok(Selection::Continuation { branch, pr }) } (None, _) => bail!("{branch} has an open PR but is gone from origin"), @@ -227,23 +232,6 @@ fn used(reads: &impl Reads, issue: &IssueUrl) -> Result { Ok(Used { on_origin, prs }) } -/// A local `branch` in the launch repository must be at `origin_sha`, or not -/// exist when origin has no copy, so no local-only commits are destroyed. -fn check_local_branch(reads: &impl Reads, branch: &str, origin_sha: Option<&str>) -> Result<()> { - let Some(local_sha) = reads.local_head(branch)? else { - return Ok(()); - }; - match origin_sha { - Some(origin_sha) if origin_sha == local_sha => Ok(()), - Some(_) => bail!( - "the local branch {branch} differs from origin/{branch}; push, reset or delete it first" - ), - None => { - bail!("the local branch {branch} is not on origin; push, rename or delete it first") - } - } -} - /// `issue-` is branch 1, `issue--branch-` is branch k for k ≥ 2. fn branch_number(issue: &IssueUrl, branch: &str) -> Option { let rest = branch.strip_prefix(&branch_name(issue, 1))?; @@ -545,16 +533,7 @@ mod tests { } #[test] - fn a_local_copy_of_a_fresh_branch_is_refused() { - let reads = InMemory { - local: vec![("issue-5", "abc")], - ..InMemory::default() - }; - assert_eq!( - refusal(&reads), - "the local branch issue-5 is not on origin; push, rename or delete it first" - ); - + fn advisory_preflight_refuses_the_chosen_next_numbered_branch() { let reads = InMemory { prs: vec![pr(1, "issue-5", PrState::Merged)], local: vec![("issue-5-branch-2", "abc")], @@ -566,31 +545,6 @@ mod tests { ); } - #[test] - fn a_local_copy_of_a_continued_branch_at_another_head_is_refused() { - let reads = InMemory { - origin: vec![("issue-5", "abc")], - local: vec![("issue-5", "def")], - ..InMemory::default() - }; - - assert_eq!( - refusal(&reads), - "the local branch issue-5 differs from origin/issue-5; push, reset or delete it first" - ); - } - - #[test] - fn a_local_copy_at_origins_head_is_continued() { - let reads = InMemory { - origin: vec![("issue-5", "abc")], - local: vec![("issue-5", "abc")], - ..InMemory::default() - }; - - assert_eq!(picked(&reads), continued("issue-5", None)); - } - #[test] fn selection_lists_origin_and_the_prs_once_whatever_the_history() { let reads = InMemory { diff --git a/src/worktree.rs b/src/worktree.rs index abb678ec9..a38ddfb33 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -13,6 +13,8 @@ use crate::git::Git; use crate::host; use crate::progress; +#[cfg(test)] +mod acquisition_tests; #[cfg(test)] mod preservation_tests; @@ -45,33 +47,36 @@ pub struct Worktree { impl Worktree { /// Create `branch` fresh from `origin/` in a new worktree next to the - /// launch repository's root, named `-`. + /// launch repository's root, named `-`. Under the worktree + /// lock, pin the fetched Base branch commit, refuse any existing local + /// Issue branch and create without force. No selection preflight is required. pub fn create_fresh(launch: &Git, repo: &str, branch: &str, base: &str) -> Result { let _lock = lock_launch(launch)?; launch.run(&["fetch", "origin", base])?; - Self::add( - launch, - repo, - branch, - &["-b", branch], - &format!("origin/{base}"), - ) + let source = format!("origin/{base}"); + let commit = launch.run(&["rev-parse", &source])?; + check_local_branch(branch, local_head(launch, branch)?.as_deref(), None)?; + Self::add(launch, repo, branch, &["-b", branch], &commit, &source) } /// Check out the existing `branch` from origin in a new worktree next to /// the launch repository's root, named `-`. `origin/` - /// is fetched too, for the review fixed point. A local `branch`, if any, is - /// reset to origin's: `branch::select` has checked they already match. + /// is fetched too, for the review fixed point. Under the worktree lock, + /// pin the fetched head and refuse any different local head. An equal + /// branch is attached without resetting it; an absent one is created + /// without force at the pinned commit. No selection preflight is required. pub fn continue_existing(launch: &Git, repo: &str, branch: &str, base: &str) -> Result { let _lock = lock_launch(launch)?; launch.run(&["fetch", "origin", base, branch])?; - Self::add( - launch, - repo, - branch, - &["-B", branch], - &format!("origin/{branch}"), - ) + let source = format!("origin/{branch}"); + let commit = launch.run(&["rev-parse", &source])?; + let local = local_head(launch, branch)?; + check_local_branch(branch, local.as_deref(), Some(&commit))?; + if local.is_some() { + Self::add(launch, repo, branch, &[], branch, &source) + } else { + Self::add(launch, repo, branch, &["-b", branch], &commit, &source) + } } fn add( @@ -80,10 +85,11 @@ impl Worktree { branch: &str, branch_args: &[&str], start: &str, + source: &str, ) -> Result { let (root, path) = sibling(launch, &format!("{repo}-{branch}"))?; progress::step(format_args!( - "creating worktree {} on {branch} from {start}", + "creating worktree {} on {branch} from {source}", path.display() )); add_worktree(launch, branch_args, &path, start)?; @@ -263,6 +269,39 @@ impl Worktree { } } +/// The local Issue branch head sampled for acquisition or advisory preflight. +pub(crate) fn local_head(launch: &Git, branch: &str) -> Result> { + launch.run_optional(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) +} + +/// Refuse local work acquisition would replace. A fresh checkout requires +/// absence; a Continuation permits absence or exact equality with its +/// sampled origin head. Selection shares this rule for advisory preflight; +/// acquisition checks it again under the worktree lock after fetching. +pub(crate) fn check_local_branch( + branch: &str, + local_head: Option<&str>, + origin_head: Option<&str>, +) -> Result<()> { + let Some(local_head) = local_head else { + return Ok(()); + }; + match origin_head { + Some(origin_head) if origin_head == local_head => Ok(()), + Some(_) => bail!( + "the local branch {branch} differs from origin/{branch}; push, reset or delete it first" + ), + None => { + bail!("the local branch {branch} is not on origin; push, rename or delete it first") + } + } +} + impl Drop for Worktree { fn drop(&mut self) { let path = self.path().to_string_lossy().into_owned(); diff --git a/src/worktree/acquisition_tests.rs b/src/worktree/acquisition_tests.rs new file mode 100644 index 000000000..496c61e6b --- /dev/null +++ b/src/worktree/acquisition_tests.rs @@ -0,0 +1,301 @@ +//! Acquisition through Worktree, with real local Git and origin. + +use super::*; + +const BRANCH: &str = "issue-7"; + +fn continuation() -> (tempfile::TempDir, Git, String) { + let (temp, launch) = super::tests::launch_directory(); + launch.run(&["checkout", "-q", "-b", BRANCH]).unwrap(); + launch + .run(&["commit", "-q", "--allow-empty", "-m", "Earlier work"]) + .unwrap(); + launch.run(&["push", "-q", "origin", BRANCH]).unwrap(); + let head = launch.run(&["rev-parse", "HEAD"]).unwrap(); + launch.run(&["checkout", "-q", "main"]).unwrap(); + (temp, launch, head) +} + +#[test] +fn continuation_refuses_local_only_work_without_selection_and_preserves_its_head() { + let (temp, launch, origin_head) = continuation(); + launch.run(&["checkout", "-q", BRANCH]).unwrap(); + launch + .run(&["commit", "-q", "--allow-empty", "-m", "Local only"]) + .unwrap(); + let local_head = launch.run(&["rev-parse", "HEAD"]).unwrap(); + launch.run(&["checkout", "-q", "main"]).unwrap(); + + let result = Worktree::continue_existing(&launch, "work", BRANCH, "main"); + + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), local_head); + assert!(!temp.path().join("work-issue-7").exists()); + assert_eq!( + Git::new(temp.path().join("origin.git")) + .run(&["rev-parse", BRANCH]) + .unwrap(), + origin_head + ); + assert_eq!( + result.err().expect("acquisition should refuse").to_string(), + "the local branch issue-7 differs from origin/issue-7; push, reset or delete it first" + ); +} + +#[test] +fn fresh_acquisition_refuses_an_existing_local_branch_even_at_the_base_head() { + let (temp, launch) = super::tests::launch_directory(); + let head = launch.run(&["rev-parse", "HEAD"]).unwrap(); + launch.run(&["branch", BRANCH]).unwrap(); + let worktrees = launch.run(&["worktree", "list", "--porcelain"]).unwrap(); + + let result = Worktree::create_fresh(&launch, "work", BRANCH, "main"); + + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), head); + assert!(!temp.path().join("work-issue-7").exists()); + assert_eq!( + launch.run(&["worktree", "list", "--porcelain"]).unwrap(), + worktrees + ); + assert_eq!( + result.err().expect("acquisition should refuse").to_string(), + "the local branch issue-7 is not on origin; push, rename or delete it first" + ); +} + +#[test] +fn continuation_refuses_a_local_commit_added_after_successful_preflight() { + let (temp, launch, origin_head) = continuation(); + check_local_branch( + BRANCH, + local_head(&launch, BRANCH).unwrap().as_deref(), + Some(&origin_head), + ) + .unwrap(); + launch.run(&["checkout", "-q", BRANCH]).unwrap(); + launch + .run(&["commit", "-q", "--allow-empty", "-m", "After preflight"]) + .unwrap(); + let local_head = launch.run(&["rev-parse", "HEAD"]).unwrap(); + launch.run(&["checkout", "-q", "main"]).unwrap(); + let worktrees = launch.run(&["worktree", "list", "--porcelain"]).unwrap(); + + let result = Worktree::continue_existing(&launch, "work", BRANCH, "main"); + + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), local_head); + assert_eq!( + launch.run(&["rev-parse", "origin/issue-7"]).unwrap(), + origin_head + ); + assert!(!temp.path().join("work-issue-7").exists()); + assert_eq!( + launch.run(&["worktree", "list", "--porcelain"]).unwrap(), + worktrees + ); + assert_eq!( + result.err().expect("acquisition should refuse").to_string(), + "the local branch issue-7 differs from origin/issue-7; push, reset or delete it first" + ); +} + +#[test] +fn continuation_refuses_a_local_ancestor_when_origin_advances_after_preflight() { + let (temp, launch, local_head) = continuation(); + check_local_branch(BRANCH, Some(&local_head), Some(&local_head)).unwrap(); + launch + .run(&["checkout", "-q", "-b", "other-machine", BRANCH]) + .unwrap(); + launch + .run(&["commit", "-q", "--allow-empty", "-m", "Origin advanced"]) + .unwrap(); + let origin_head = launch.run(&["rev-parse", "HEAD"]).unwrap(); + launch.run(&["checkout", "-q", "main"]).unwrap(); + let origin = Git::new(temp.path().join("origin.git")); + origin + .run(&[ + "fetch", + launch.dir().to_str().unwrap(), + "other-machine:issue-7", + ]) + .unwrap(); + assert_eq!( + launch.run(&["rev-parse", "origin/issue-7"]).unwrap(), + local_head + ); + let worktrees = launch.run(&["worktree", "list", "--porcelain"]).unwrap(); + + let result = Worktree::continue_existing(&launch, "work", BRANCH, "main"); + + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), local_head); + assert_eq!( + launch.run(&["rev-parse", "origin/issue-7"]).unwrap(), + origin_head + ); + assert_eq!(origin.run(&["rev-parse", BRANCH]).unwrap(), origin_head); + assert_eq!( + launch.run(&["rev-parse", "other-machine"]).unwrap(), + origin_head + ); + assert!(!temp.path().join("work-issue-7").exists()); + assert_eq!( + launch.run(&["worktree", "list", "--porcelain"]).unwrap(), + worktrees + ); + assert_eq!( + result.err().expect("acquisition should refuse").to_string(), + "the local branch issue-7 differs from origin/issue-7; push, reset or delete it first" + ); +} + +#[test] +fn continuation_attaches_an_equal_local_branch_without_resetting_and_cleans_up() { + let (temp, launch, head) = continuation(); + let reflog = launch + .run(&["reflog", "show", "--format=%H %gs", BRANCH]) + .unwrap(); + + let worktree = Worktree::continue_existing(&launch, "work", BRANCH, "main").unwrap(); + + assert_eq!(worktree.head().unwrap(), head); + assert_eq!(worktree.branch(), BRANCH); + assert_eq!( + worktree.path(), + temp.path().join("work-issue-7").canonicalize().unwrap() + ); + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), head); + assert_eq!( + launch + .run(&["reflog", "show", "--format=%H %gs", BRANCH]) + .unwrap(), + reflog + ); + drop(worktree); + assert!(!temp.path().join("work-issue-7").exists()); + assert!(local_head(&launch, BRANCH).unwrap().is_none()); + assert_eq!( + Git::new(temp.path().join("origin.git")) + .run(&["rev-parse", BRANCH]) + .unwrap(), + head + ); +} + +#[test] +fn absent_issue_branches_are_created_at_the_fetched_start_and_cleaned_up() { + for continuing in [false, true] { + let (temp, launch, head) = continuation(); + launch.run(&["branch", "-D", BRANCH]).unwrap(); + let expected = if continuing { + head + } else { + launch.run(&["rev-parse", "origin/main"]).unwrap() + }; + + let worktree = if continuing { + Worktree::continue_existing(&launch, "work", BRANCH, "main") + } else { + Worktree::create_fresh(&launch, "work", BRANCH, "main") + } + .unwrap(); + + assert_eq!(worktree.head().unwrap(), expected); + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), expected); + assert_eq!( + Git::new(worktree.path()) + .run(&["symbolic-ref", "--short", "HEAD"]) + .unwrap(), + BRANCH + ); + assert_eq!( + worktree.path(), + temp.path().join("work-issue-7").canonicalize().unwrap() + ); + drop(worktree); + assert!(!temp.path().join("work-issue-7").exists()); + assert!(local_head(&launch, BRANCH).unwrap().is_none()); + } +} + +#[test] +fn occupied_paths_survive_add_failure_with_pre_existing_branch_heads_untouched() { + for (continuing, local_exists) in [(false, false), (true, false), (true, true)] { + let (temp, launch, issue_head) = continuation(); + if !local_exists { + launch.run(&["branch", "-D", BRANCH]).unwrap(); + } + let main_head = launch.run(&["rev-parse", "main"]).unwrap(); + let worktrees = launch.run(&["worktree", "list", "--porcelain"]).unwrap(); + let occupied = temp.path().join("work-issue-7"); + std::fs::create_dir(&occupied).unwrap(); + std::fs::write(occupied.join("local.txt"), "keep this work\n").unwrap(); + + let result = if continuing { + Worktree::continue_existing(&launch, "work", BRANCH, "main") + } else { + Worktree::create_fresh(&launch, "work", BRANCH, "main") + }; + + assert_eq!(launch.run(&["rev-parse", "main"]).unwrap(), main_head); + if local_exists { + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), issue_head); + } + assert_eq!( + Git::new(temp.path().join("origin.git")) + .run(&["rev-parse", BRANCH]) + .unwrap(), + issue_head + ); + assert_eq!( + std::fs::read_to_string(occupied.join("local.txt")).unwrap(), + "keep this work\n" + ); + assert_eq!(std::fs::read_dir(&occupied).unwrap().count(), 1); + assert_eq!( + launch.run(&["worktree", "list", "--porcelain"]).unwrap(), + worktrees + ); + let error = result + .err() + .expect("occupied path should refuse") + .to_string(); + assert!(error.starts_with("git worktree add"), "{error}"); + assert!(error.contains("already exists"), "{error}"); + } +} + +#[test] +fn a_branch_in_another_worktree_is_refused_without_touching_its_edits() { + let (temp, launch, head) = continuation(); + let existing = temp.path().join("manual-worktree"); + launch + .run(&["worktree", "add", existing.to_str().unwrap(), BRANCH]) + .unwrap(); + std::fs::write(existing.join("wip.txt"), "manual work\n").unwrap(); + let worktrees = launch.run(&["worktree", "list", "--porcelain"]).unwrap(); + + let result = Worktree::continue_existing(&launch, "work", BRANCH, "main"); + + assert_eq!(launch.run(&["rev-parse", BRANCH]).unwrap(), head); + assert_eq!( + Git::new(&existing).run(&["rev-parse", "HEAD"]).unwrap(), + head + ); + assert_eq!( + std::fs::read_to_string(existing.join("wip.txt")).unwrap(), + "manual work\n" + ); + assert!(!temp.path().join("work-issue-7").exists()); + assert_eq!( + launch.run(&["worktree", "list", "--porcelain"]).unwrap(), + worktrees + ); + let error = result + .err() + .expect("checked-out branch should refuse") + .to_string(); + assert!(error.starts_with("git worktree add"), "{error}"); + assert!( + error.contains("already checked out") || error.contains("already used by worktree"), + "{error}" + ); +} diff --git a/tests/continuation.rs b/tests/continuation.rs index 0fee77730..b436142bf 100644 --- a/tests/continuation.rs +++ b/tests/continuation.rs @@ -111,9 +111,11 @@ fn assert_nothing_created(scenario: &Scenario, local_branch: &str) { #[test] fn a_local_issue_branch_that_differs_from_origin_stops_the_run() { let scenario = Scenario::new(); + scenario.issue_labelled(7, &["ready-for-agent"]); scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); scenario.launch_checks_out("issue-7"); scenario.launch_git(&["commit", "-q", "--allow-empty", "-m", "Local only"]); + let local_head = scenario.launch_git(&["rev-parse", "HEAD"]); scenario.launch_checks_out("main"); scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); @@ -129,6 +131,8 @@ fn a_local_issue_branch_that_differs_from_origin_stops_the_run() { result.stderr ); assert_nothing_created(&scenario, "issue-7"); + assert_eq!(scenario.issue_labels(7), ["ready-for-agent"]); + assert_eq!(scenario.launch_git(&["rev-parse", "issue-7"]), local_head); assert_eq!( scenario.launch_git(&["log", "-1", "--format=%s", "issue-7"]), "Local only\n" @@ -136,6 +140,64 @@ fn a_local_issue_branch_that_differs_from_origin_stops_the_run() { assert_eq!(scenario.origin_log("issue-7").map(|log| log.len()), Some(2)); } +#[test] +fn a_local_branch_added_after_selection_fails_acquisition_and_ends_the_claim() { + for continuing in [false, true] { + let scenario = Scenario::new(); + scenario.issue_labelled(7, &["ready-for-agent"]); + if continuing { + scenario.origin_has_branch("issue-7", "main", &["Earlier work"]); + } + let mut state = scenario.gh_state(); + // The preflight issue read installs a callback for the Claim's read, + // which happens after selection. This deterministically changes the + // branch before acquisition without changing the Run's ordering. + state["on_issue_view"]["7"] = + serde_json::json!("gh fake on-issue-view 7 'bash \"$FAKE_GH_STATE.after-selection\"'"); + scenario.write_gh_state(&state); + std::fs::write( + scenario.path("gh-state.json.after-selection"), + r#" +git checkout -q -b issue-7 +git commit -q --allow-empty -m 'After selection' +git rev-parse HEAD > "$FAKE_GH_STATE.local-head" +git checkout -q main +"#, + ) + .unwrap(); + scenario.agent_does(AGENT_COMMITS_AND_OPENS_PR); + + let result = scenario.run(&[&scenario.issue_url(7)]); + + assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); + assert_eq!(result.stdout, ""); + let message = if continuing { + "the local branch issue-7 differs from origin/issue-7; push, reset or delete it first" + } else { + "the local branch issue-7 is not on origin; push, rename or delete it first" + }; + assert!(result.stderr.contains(message), "stderr: {}", result.stderr); + assert_nothing_created(&scenario, "issue-7"); + assert_eq!( + scenario.launch_git(&["rev-parse", "issue-7"]), + std::fs::read_to_string(scenario.path("gh-state.json.local-head")).unwrap() + ); + assert_eq!( + scenario.issue_labels(7), + if continuing { + ["in-progress"] + } else { + ["ready-for-agent"] + } + ); + assert_eq!(scenario.gh_state()["prs"], serde_json::json!([])); + assert_eq!( + scenario.origin_log("issue-7").map(|log| log.len()), + if continuing { Some(2) } else { None } + ); + } +} + #[test] fn a_failed_runs_branch_is_continued_from_its_failure_commit() { let scenario = Scenario::new(); From 410c05011ce3d34172a09f1d6af77732f2b1436b Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Wed, 7 Oct 2026 00:33:10 -0400 Subject: [PATCH 2/2] Pin acquisition to unambiguous origin tracking refs (#485) --- src/worktree.rs | 4 ++-- src/worktree/acquisition_tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/worktree.rs b/src/worktree.rs index a38ddfb33..f215b4606 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -54,7 +54,7 @@ impl Worktree { let _lock = lock_launch(launch)?; launch.run(&["fetch", "origin", base])?; let source = format!("origin/{base}"); - let commit = launch.run(&["rev-parse", &source])?; + let commit = launch.run(&["rev-parse", &format!("refs/remotes/{source}")])?; check_local_branch(branch, local_head(launch, branch)?.as_deref(), None)?; Self::add(launch, repo, branch, &["-b", branch], &commit, &source) } @@ -69,7 +69,7 @@ impl Worktree { let _lock = lock_launch(launch)?; launch.run(&["fetch", "origin", base, branch])?; let source = format!("origin/{branch}"); - let commit = launch.run(&["rev-parse", &source])?; + let commit = launch.run(&["rev-parse", &format!("refs/remotes/{source}")])?; let local = local_head(launch, branch)?; check_local_branch(branch, local.as_deref(), Some(&commit))?; if local.is_some() { diff --git a/src/worktree/acquisition_tests.rs b/src/worktree/acquisition_tests.rs index 496c61e6b..701bffa87 100644 --- a/src/worktree/acquisition_tests.rs +++ b/src/worktree/acquisition_tests.rs @@ -299,3 +299,34 @@ fn a_branch_in_another_worktree_is_refused_without_touching_its_edits() { "{error}" ); } + +#[test] +fn acquisition_pins_origin_even_when_local_branches_shadow_remote_tracking_names() { + let (temp, launch, issue_head) = continuation(); + let base_head = launch.run(&["rev-parse", "main"]).unwrap(); + launch.run(&["branch", "origin/issue-7", "main"]).unwrap(); + launch.run(&["branch", "origin/main", BRANCH]).unwrap(); + + let continued = Worktree::continue_existing(&launch, "work", BRANCH, "main").unwrap(); + assert_eq!(continued.head().unwrap(), issue_head); + drop(continued); + + let fresh = Worktree::create_fresh(&launch, "work", BRANCH, "main").unwrap(); + assert_eq!(fresh.head().unwrap(), base_head); + drop(fresh); + + assert_eq!( + launch + .run(&["rev-parse", "refs/heads/origin/issue-7"]) + .unwrap(), + base_head + ); + assert_eq!( + launch + .run(&["rev-parse", "refs/heads/origin/main"]) + .unwrap(), + issue_head + ); + assert!(!temp.path().join("work-issue-7").exists()); + assert!(local_head(&launch, BRANCH).unwrap().is_none()); +}