Skip to main content

workon/
resolve.rs

1//! Stack-aware worktree resolution for `workon <name>`.
2//!
3//! Implements the ordered cascade from ADR-024:
4//!
5//! 1. **T has its own worktree** → navigate (`cd`, no new directory).
6//! 2. **Current worktree's branch shares T's stack** → checkout T in place. *(added in PR-2)*
7//! 3. **Nearest ancestor-or-self of T has a worktree** → checkout T there. *(added in PR-2)*
8//!    "Self" is a worktree sitting at the path `New` would create for T while another
9//!    branch is checked out in it (T's own home, hijacked by `gt create` or `git switch`).
10//!    It is checked right after rule 1, before rule 2, and applies under every model.
11//!    Ancestors exclude the trunk.
12//! 4. **Otherwise** → materialize (auto-attach existing branch or create new branch).
13//!
14//! Non-stack users ([`StackModel::None`]) skip the stack-based part of rules 2 and 3, but
15//! can still get [`Resolution::Checkout`] when T's own home worktree is hijacked; otherwise
16//! they see [`Resolution::Navigate`], [`Resolution::Materialize`], or
17//! [`Resolution::NotFound`], preserving the original worktree-per-branch behavior.
18
19use git2::{BranchType, Repository};
20
21use crate::stack::{current_stack, StackModel};
22use crate::worktree::{current_worktree, find_worktree, find_worktree_by_branch, get_worktrees};
23use crate::worktree_name::relative_worktree_path;
24
25/// The resolved action for `workon <T>`.
26///
27/// The caller maps this to a `Cmd`:
28/// - [`Navigate`] and [`NotFound`] → fall through to `Cmd::Find`
29/// - [`Materialize`] → `Cmd::New`
30/// - [`Checkout`] → `Cmd::Checkout` *(added in PR-2)*
31/// - [`DeletedNode`] → structured error *(added in PR-7)*; currently treated as `NotFound`
32///
33/// [`Navigate`]: Resolution::Navigate
34/// [`NotFound`]: Resolution::NotFound
35/// [`Materialize`]: Resolution::Materialize
36/// [`Checkout`]: Resolution::Checkout
37/// [`DeletedNode`]: Resolution::DeletedNode
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Resolution {
40    /// T has its own worktree — navigate to it (`cd`).
41    Navigate,
42    /// T belongs to a stack whose home worktree is `host` — checkout T in place there.
43    ///
44    /// The `host` string is the worktree name (not path). Added in PR-2.
45    Checkout { host: String },
46    /// Materialize a worktree for T (auto-attach existing branch, or create new branch).
47    Materialize,
48    /// T exists in stack metadata but its branch ref was deleted.
49    ///
50    /// Per ADR-024: errors with guidance pointing at `gt` rather than attempting to
51    /// recreate the branch. Surfaced as `StackError::DeletedBranchNode` in PR-7;
52    /// until then, falls through to `Find` (which shows a "no match" error).
53    DeletedNode { branch: String },
54    /// No worktree, no branch, no metadata match — fall through to `Find`.
55    NotFound,
56}
57
58/// Resolve the action for `workon <name>` under the given stack model.
59///
60/// Implements the ordered cascade from ADR-024.
61///
62/// # Rule summary
63///
64/// 1. T has its own worktree → [`Navigate`]. git's lock makes this correctly first.
65/// 2. Current worktree's branch shares T's stack → [`Checkout`] in current worktree. *(PR-2)*
66/// 3. Nearest ancestor-or-self of T with a worktree → [`Checkout`] there. *(PR-2)*
67///    Self (a worktree at T's path with another branch checked out) is checked right
68///    after rule 1, before rule 2, and also under [`StackModel::None`]. The trunk is
69///    excluded from the ancestor walk only.
70/// 4. Branch exists with no worktree → [`Materialize`]; metadata without a ref →
71///    [`DeletedNode`]; nothing matches → [`NotFound`].
72///
73/// Under [`StackModel::None`] rule 2 and the ancestor walk are skipped; rule 1, the
74/// self-home check, and rule 4 fire.
75///
76/// [`Navigate`]: Resolution::Navigate
77/// [`Checkout`]: Resolution::Checkout
78/// [`Materialize`]: Resolution::Materialize
79/// [`DeletedNode`]: Resolution::DeletedNode
80/// [`NotFound`]: Resolution::NotFound
81pub fn resolve_action(repo: &Repository, name: &str, model: StackModel) -> Resolution {
82    // ── Rule 1 ───────────────────────────────────────────────────────────────
83    // T has its own worktree → navigate.
84    // Matched by *branch* only: after an in-place checkout a stack home keeps its
85    // name but moves to another branch, and navigating to the stale name would
86    // land the user on the wrong branch (and skip T's checkout + stash restore).
87    // Name-based navigation still works via Find's NotFound fallthrough.
88    // git's lock (it forbids checkout of a branch that is live in another worktree)
89    // makes this check correctly first.
90    if find_worktree_by_branch(repo, name).is_ok() {
91        return Resolution::Navigate;
92    }
93
94    // ── Self-home ────────────────────────────────────────────────────────────
95    // A worktree sits at the path `New` would create for T but has another branch
96    // checked out (e.g. `main` hijacked by `gt create`). `New` would collide with it,
97    // so return T to its home instead. Matched by root-relative path, not
98    // `find_worktree`, which also matches branch and admin names. Runs before rule 2
99    // so T's own home wins over a same-stack checkout in the cwd, and before the
100    // `None` early return so non-stack repos get it too. Requires a local T: with
101    // the branch gone, the checkout would fail, and Find's name match should take
102    // the user to the worktree instead.
103    let local_branch = repo.find_branch(name, BranchType::Local).is_ok();
104    if let (true, Ok(worktrees)) = (local_branch, get_worktrees(repo)) {
105        for wt in worktrees {
106            if relative_worktree_path(repo, wt.path()).as_deref() == Some(name)
107                && wt.branch().ok().flatten().as_deref() != Some(name)
108            {
109                if let Some(n) = wt.name() {
110                    return Resolution::Checkout {
111                        host: n.to_string(),
112                    };
113                }
114            }
115        }
116    }
117
118    // ── Non-stack degradation ─────────────────────────────────────────────────
119    // Under StackModel::None (or --no-stack), every branch is a stack-of-one.
120    // Stack-based rules 2–3 never fire (self-home above aside) → worktree-per-branch behavior.
121    if model == StackModel::None {
122        return if branch_exists(repo, name) {
123            Resolution::Materialize
124        } else {
125            Resolution::NotFound
126        };
127    }
128
129    // ── Rules 2 and 3 ────────────────────────────────────────────────────────
130    // In-place checkout (move HEAD within an existing worktree, no new directory).
131    let t_stack = current_stack(repo, name, model).ok().flatten();
132
133    // Rule 2: current worktree's branch shares T's stack → checkout T in place.
134    // The trunk does not count as "sharing the stack": ADR-024's invariant is
135    // that the trunk worktree is never a checkout host, so sitting on trunk
136    // falls through to rules 3–4 instead of hijacking the trunk worktree.
137    if let (Ok(cur), Some(ts)) = (current_worktree(repo), &t_stack) {
138        if let Ok(Some(b)) = cur.branch() {
139            if ts.diffs.contains(&b) {
140                if let Some(n) = cur.name() {
141                    return Resolution::Checkout {
142                        host: n.to_string(),
143                    };
144                }
145            }
146        }
147    }
148
149    // Rule 3: deepest non-trunk ancestor of T with a worktree → checkout T there.
150    // Walk the parent chain nearest-first; first ancestor that has a worktree wins.
151    // The visited set bounds the walk even if the metadata's parent graph is
152    // cyclic — termination must not depend on how the map was built.
153    if let Some(ts) = &t_stack {
154        let mut visited = std::collections::HashSet::new();
155        let mut node = name.to_string();
156        while let Some(parent) = ts.parents.get(&node) {
157            if *parent == ts.trunk || !visited.insert(parent.clone()) {
158                break; // never host on the trunk worktree
159            }
160            if let Ok(wt) = find_worktree(repo, parent) {
161                if let Some(n) = wt.name() {
162                    return Resolution::Checkout {
163                        host: n.to_string(),
164                    };
165                }
166            }
167            node = parent.clone();
168        }
169    }
170
171    // ── Rule 4 ───────────────────────────────────────────────────────────────
172    // Materialize if a branch ref exists (auto-attach); otherwise distinguish a
173    // deleted ◯ node (metadata exists, no ref) from a plain typo.
174    if branch_exists(repo, name) {
175        return Resolution::Materialize;
176    }
177
178    // A branch can appear in stack metadata (it was `gt track`-ed) but have its
179    // local ref deleted. ADR-024 says this should error pointing at gt rather than
180    // silently trying to recreate the branch.
181    if t_stack.is_some() {
182        return Resolution::DeletedNode {
183            branch: name.to_string(),
184        };
185    }
186
187    Resolution::NotFound
188}
189
190/// Returns `true` if `name` matches a local branch or the short name of any
191/// remote tracking branch (the part after the first `/`).
192///
193/// Moved from `git-workon/src/main.rs` into the library so [`resolve_action`]
194/// can use it without re-opening the repository.
195pub fn branch_exists(repo: &Repository, name: &str) -> bool {
196    if repo.find_branch(name, BranchType::Local).is_ok() {
197        return true;
198    }
199    if let Ok(branches) = repo.branches(Some(BranchType::Remote)) {
200        for branch in branches.flatten() {
201            if let Ok(Some(full_name)) = branch.0.name() {
202                // Remote branch names are "remote/branch" — match the part after the first "/".
203                if let Some((_, branch_name)) = full_name.split_once('/') {
204                    if branch_name == name {
205                        return true;
206                    }
207                }
208            }
209        }
210    }
211    false
212}