workon/checkout.rs
1//! In-place branch checkout within an existing worktree.
2//!
3//! Moves HEAD within a worktree's working directory without creating a new worktree
4//! directory. This is the primitive backing [`Resolution::Checkout`] from ADR-024:
5//! when a target branch `T` belongs to the same stack as a worktree `W`, checking
6//! out `T` inside `W` avoids the git lock that prevents rebasing a branch that is
7//! live in another worktree.
8//!
9//! ## git2 vs porcelain
10//!
11//! Uses `checkout_tree` + `set_head` rather than shelling out to `git checkout`
12//! so the operation is consistent with the rest of the library (which shells out
13//! only for `gt`/`gh`, which have no git2 API).
14//!
15//! `CheckoutBuilder::safe()` carries non-conflicting local changes across the HEAD
16//! move "for free" — the same semantics as `git checkout --merge`. Known limitation:
17//! SAFE mode does not handle submodules or sparse-checkout patterns; those edge cases
18//! are documented as out-of-scope in ADR-024.
19//!
20//! On conflict HEAD is never moved, so an `Err(CheckoutError::Conflict { .. })` is
21//! a clean no-op — the caller can either abort or stash-and-retry (PR-3).
22
23use std::cell::RefCell;
24
25use git2::{build::CheckoutBuilder, Repository};
26
27use crate::error::{CheckoutError, Result};
28
29/// The outcome of a successful [`checkout_branch_in_worktree`] call.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum CheckoutOutcome {
32 /// HEAD was moved cleanly; any non-conflicting local changes were carried along.
33 Clean,
34 /// One or more files conflict with the new HEAD. HEAD was **not** moved.
35 Conflict { paths: Vec<String> },
36}
37
38/// Move HEAD in the worktree opened as `wt_repo` to `branch`.
39///
40/// `wt_repo` must be a [`Repository`] opened on the worktree's path (not the
41/// bare root) so HEAD/index target that worktree's working directory — the
42/// same handle the stash operations take, so one open serves the whole
43/// checkout flow. Resolves `refs/heads/<branch>`, performs a safe checkout
44/// (`CheckoutBuilder::safe()`), then updates HEAD with `set_head`. On conflict
45/// HEAD is left unmoved and [`CheckoutOutcome::Conflict`] is returned rather than
46/// `Err`, so the caller can prompt before deciding to stash-and-retry (PR-3) or
47/// abort.
48///
49/// Returns `Err` only for genuine git errors (branch not found, I/O, etc.).
50pub fn checkout_branch_in_worktree(wt_repo: &Repository, branch: &str) -> Result<CheckoutOutcome> {
51 let branch_ref = wt_repo
52 .find_branch(branch, git2::BranchType::Local)
53 .map_err(|_| CheckoutError::BranchNotFound {
54 branch: branch.to_string(),
55 })?;
56
57 let target_commit = branch_ref
58 .get()
59 .peel_to_commit()
60 .map_err(CheckoutError::Git)?;
61 let target_tree = target_commit.tree().map_err(CheckoutError::Git)?;
62
63 // Collect conflicting paths via a notify callback. The callback runs
64 // synchronously on this thread while checkout_tree executes, so a local
65 // RefCell borrowed by the closure is all the sharing needed.
66 let conflicts: RefCell<Vec<String>> = RefCell::new(Vec::new());
67
68 let mut builder = CheckoutBuilder::new();
69 builder.safe();
70 builder.notify_on(git2::CheckoutNotificationType::CONFLICT);
71 builder.notify(|_kind, path, _baseline, _target, _workdir| {
72 if let Some(p) = path {
73 if let Some(s) = p.to_str() {
74 conflicts.borrow_mut().push(s.to_string());
75 }
76 }
77 true // continue collecting
78 });
79
80 match wt_repo.checkout_tree(target_tree.as_object(), Some(&mut builder)) {
81 Ok(()) => {}
82 Err(e) if e.code() == git2::ErrorCode::Conflict => {
83 return Ok(CheckoutOutcome::Conflict {
84 paths: conflicts.take(),
85 });
86 }
87 Err(e) => return Err(CheckoutError::Git(e).into()),
88 }
89
90 wt_repo
91 .set_head(&format!("refs/heads/{}", branch))
92 .map_err(CheckoutError::Git)?;
93
94 Ok(CheckoutOutcome::Clean)
95}