Skip to main content

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}