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