Skip to main content

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}