Skip to main content

workon/
stack.rs

1//! Stacked diff workflow support.
2//!
3//! This module provides the infrastructure for detecting and interacting with
4//! stacked-diff tools alongside git-workon's worktree management.
5//!
6//! ## Model
7//!
8//! Stack awareness is two-dimensional:
9//!
10//! - [`StackModel`] — which tool manages stacks (v1: Graphite, and metadata-less git-inference
11//!   via [`StackModel::Git`]; future: branchless, sapling, spr)
12//! - [`Granularity`] — how worktrees map to stacks (v1: [`Granularity::Stack`], one per stack)
13//!
14//! ## Default-on behavior
15//!
16//! When `workon.stackModel` resolves to anything other than [`StackModel::None`] (by explicit
17//! config or auto-detection), every command that has a meaningful stack-aware variant uses it
18//! by default. A `--no-stack` CLI flag (global across subcommands) downgrades any single
19//! invocation back to branch-flat behavior.
20//!
21//! ## Graphite (v1)
22//!
23//! Stack metadata is read directly from `refs/branch-metadata/*` git refs — blobs containing
24//! JSON written by the `gt` CLI. No `gt` process is needed for detection or visualization.
25//! `gt track` is invoked only when registering a new branch (in `workon new` when creating a
26//! fork off a stack-worktree's branch).
27//!
28//! ## Git-inference (v1)
29//!
30//! [`StackModel::Git`] covers repositories with no stack tool at all: [`enumerate_stacks`] and
31//! [`current_stack`] treat it exactly like [`StackModel::None`] (no branch-level topology to
32//! report), but [`crate::assemble_changesets`] gives it meaning by walking `upstream..HEAD`,
33//! emitting one changeset per commit. It exists purely as an assembly-time model — see
34//! [`StackModel::detect`] for why it is never auto-detected.
35//!
36//! ## Cross-worktree grouping
37//!
38//! [`group_by_stack`] partitions a parallel slice of [`Option<Stack>`] values (one per worktree)
39//! into [`StackGrouping`]: a list of [`StackGroup`]s (each covering one connected stack) plus an
40//! `ungrouped` list for trunk/untracked worktrees. The grouping key is `(trunk, sorted diff
41//! set)`, so two worktrees in the same connected stack always collapse into one group regardless
42//! of the [`Stack::diffs`] vector order returned by [`current_stack`].
43
44pub(crate) mod graphite;
45
46use std::collections::{BTreeMap, HashMap};
47
48use git2::Repository;
49
50use crate::error::Result;
51
52/// Which stacked-diff tool is managing stacks in this repository.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum StackModel {
55    /// No stack tool; today's branch-flat behavior.
56    None,
57    /// Graphite (`gt`) manages stacks via `refs/branch-metadata/*`.
58    Graphite,
59    /// No stack-metadata tool; changesets are inferred purely from git, one per commit in
60    /// `upstream..HEAD`. Unlike [`StackModel::Graphite`], this carries no branch-level stack
61    /// topology: [`enumerate_stacks`] and [`current_stack`] treat it as flat (same as
62    /// [`StackModel::None`]), since there is no metadata to enumerate stacks from or to
63    /// group branches into. Only [`crate::assemble_changesets`] (M1 changeset assembly)
64    /// gives this variant meaning, walking `upstream..HEAD` per-commit.
65    ///
66    /// Not reachable via [`StackModel::detect`] — see the module docs' Default-on-behavior
67    /// note on why auto-detection never resolves to `Git`.
68    Git,
69}
70
71impl StackModel {
72    /// Auto-detect the active stack model from the repository environment.
73    ///
74    /// Returns [`StackModel::Graphite`] when the repo has been initialized with `gt init`
75    /// (`.graphite_repo_config` or `.graphite_metadata.db` exists). Otherwise returns
76    /// [`StackModel::None`].
77    ///
78    /// Deliberately does NOT consult [`graphite::detect_gt`]: the repo's own metadata is the
79    /// ground truth for "is this a Graphite stack," and reading it is pure libgit2 (see the
80    /// module docs — no `gt` process is needed for detection or visualization). Gating on the
81    /// binary's presence would report `None` for a genuine Graphite stack whenever `gt` happens
82    /// to be missing from PATH, silently emptying the review TUI's stack. Whether `gt` can be
83    /// *executed* is a separate question, and belongs to the call sites that execute it.
84    ///
85    /// Never returns [`StackModel::Git`]: auto-detection only distinguishes "a stack tool is
86    /// active" from "no stack tool," since CLI routing treats any non-`None` model as
87    /// stack-active. Auto-resolving to `Git` for every repository with an upstream-tracking
88    /// branch would silently flip that routing for nearly every user. `Git` is reachable via
89    /// explicit `workon.stackModel = git` config, or a caller mapping `None` to `Git` before
90    /// calling [`crate::assemble_changesets`] (the review crate does this from M3 onward).
91    pub fn detect(repo: &Repository) -> Self {
92        if graphite::is_graphite_repo(repo) {
93            Self::Graphite
94        } else {
95            Self::None
96        }
97    }
98}
99
100/// How worktrees map to stacks.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Granularity {
103    /// One worktree hosts an entire stack. The user navigates between branches inside it
104    /// using the stack tool's own commands (e.g. `gt up` / `gt down`).
105    Stack,
106}
107
108/// A stack of branches rooted at a trunk branch.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Stack {
111    /// The trunk branch this stack is rooted on (e.g., `"main"`).
112    pub trunk: String,
113    /// All non-trunk diffs (branches) in the stack, in BFS order from bottom to top.
114    pub diffs: Vec<String>,
115    /// The branch that is currently HEAD in the worktree.
116    pub current: String,
117    /// Parent map for the diffs in this stack: `diff → parent_branch`.
118    ///
119    /// The parent may be another diff or the trunk itself. Only covers diffs in
120    /// [`Stack::diffs`]; the trunk's own parent is not recorded. Used to reconstruct
121    /// the branching tree structure (a flat [`diffs`] list cannot represent forks).
122    pub parents: HashMap<String, String>,
123}
124
125/// Return all stacks present in metadata, one per connected component.
126///
127/// Each returned [`Stack`] corresponds to one potential [`StackGroup`] — the same `(trunk,
128/// sorted diffs)` key used by [`group_by_stack`]. Used by the `list` command to surface
129/// stacks that have no checked-out worktrees.
130pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
131    match model {
132        StackModel::None => Ok(vec![]),
133        StackModel::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
134        // Git-inference has no branch-level stack topology to enumerate — flat, like None.
135        StackModel::Git => Ok(vec![]),
136    }
137}
138
139/// Return the stack for the worktree whose HEAD is `head_branch`, or `None` if the branch
140/// is not part of a tracked stack under `model`.
141///
142/// The returned [`Stack`] includes all branches reachable from the same stack root, not just
143/// the ancestors of `head_branch`, so branching stacks are fully represented.
144pub fn current_stack(
145    repo: &Repository,
146    head_branch: &str,
147    model: StackModel,
148) -> Result<Option<Stack>> {
149    match model {
150        StackModel::None => Ok(None),
151        StackModel::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
152        // Git-inference has no branch-level stack topology — flat, like None.
153        StackModel::Git => Ok(None),
154    }
155}
156
157/// Returns `true` if `gt` is on PATH and this repository has been Graphite-initialized.
158pub fn is_graphite_active(repo: &Repository) -> bool {
159    graphite::detect_gt() && graphite::is_graphite_repo(repo)
160}
161
162/// Return the first trunk branch name from `.graphite_repo_config`, or `None` if
163/// the file is missing, unparseable, or contains no trunk entries.
164///
165/// Use this when you need to pass `--parent <trunk>` to `gt track` and want to
166/// avoid hardcoding `"main"`. Returns `None` rather than a hardcoded fallback so
167/// callers can omit `--parent` entirely and let `gt` infer when the trunk is unknown.
168pub fn graphite_trunk(repo: &Repository) -> Option<String> {
169    graphite::graphite_trunk(repo)
170}
171
172/// One group of worktrees that share a connected stack, identified by trunk + diff set.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct StackGroup {
175    /// Representative stack (trunk + diffs in BFS bottom→top order) for the group.
176    pub stack: Stack,
177    /// Indices into the caller's worktree slice for members of this stack, ordered by
178    /// the member branch's position in `stack.diffs` (bottom→top). Members whose
179    /// branch isn't found in `stack.diffs` sort last, in original input order.
180    pub members: Vec<usize>,
181}
182
183/// Result of partitioning a worktree slice by stacks via [`group_by_stack`].
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct StackGrouping {
186    /// Groups of worktrees sharing a connected stack, ordered by `(trunk, sorted branch set)`.
187    pub groups: Vec<StackGroup>,
188    /// Indices of worktrees with no stack (trunk branches, untracked branches), in input order.
189    pub ungrouped: Vec<usize>,
190}
191
192/// Partition a slice of optional stack descriptors into groups sharing the same connected stack.
193///
194/// The grouping key is `(trunk, sorted diff set)`, so worktrees in the same connected stack
195/// always collapse into one group regardless of the [`Stack::diffs`] vector order returned
196/// by [`current_stack`]. Members within each group are ordered by their branch's position in
197/// the representative stack's `diffs` list (bottom→top).
198///
199/// Groups are returned in deterministic order: sorted by `(trunk, sorted branch set)`.
200///
201/// When all entries are `None` (stack model inactive or `--no-stack`), `groups` is empty and
202/// every index appears in `ungrouped` — callers get identical flat-list behavior.
203pub fn group_by_stack(stacks: &[Option<Stack>]) -> StackGrouping {
204    // BTreeMap provides sorted iteration: (trunk, branch_set) order automatically.
205    let mut map: BTreeMap<(String, Vec<String>), (Stack, Vec<usize>)> = BTreeMap::new();
206    let mut ungrouped: Vec<usize> = Vec::new();
207
208    for (i, opt) in stacks.iter().enumerate() {
209        match opt {
210            None => ungrouped.push(i),
211            Some(stack) => {
212                let mut key_branches = stack.diffs.clone();
213                key_branches.sort();
214                let key = (stack.trunk.clone(), key_branches);
215                let entry = map
216                    .entry(key)
217                    .or_insert_with(|| (stack.clone(), Vec::new()));
218                entry.1.push(i);
219            }
220        }
221    }
222
223    let groups = map
224        .into_values()
225        .map(|(rep_stack, mut members)| {
226            // Sort members by their branch's position in rep_stack.diffs (bottom→top).
227            members.sort_by_key(|&idx| {
228                let branch = stacks[idx]
229                    .as_ref()
230                    .map(|s| s.current.as_str())
231                    .unwrap_or("");
232                rep_stack
233                    .diffs
234                    .iter()
235                    .position(|b| b == branch)
236                    .unwrap_or(usize::MAX)
237            });
238            StackGroup {
239                stack: rep_stack,
240                members,
241            }
242        })
243        .collect();
244
245    StackGrouping { groups, ungrouped }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use git_workon_fixture::prelude::*;
252
253    /// `detect` must resolve `Graphite` from repo metadata alone, with no dependency on whether
254    /// `gt` is installed on the host running the tests.
255    ///
256    /// The "gt absent" half cannot be forced from inside this process: it would mean mutating
257    /// `PATH`, which is process-global (`unsafe` under Rust 2024) and would race every other test
258    /// in this binary. Only the CLI suite can scrub `PATH` hermetically, by passing it to a child
259    /// process (`git-workon/tests/suite/new.rs`'s `path_without_gt_new`). So the guard here is
260    /// environmental rather than hermetic — CI has no `gt`, so a reintroduced `detect_gt()` gate
261    /// turns this red there. Its value is making that failure say "detection must not require gt"
262    /// instead of surfacing as a handful of unrelated review-TUI changeset-count assertions.
263    #[test]
264    fn detect_resolves_graphite_from_repo_metadata_without_requiring_the_gt_binary() {
265        let fixture = FixtureBuilder::new()
266            .graphite_config(&["main"])
267            .branch_metadata("a", "main")
268            .build()
269            .unwrap();
270        let repo = fixture.repo().unwrap();
271
272        assert_eq!(
273            StackModel::detect(repo),
274            StackModel::Graphite,
275            "a gt-initialized repo is a Graphite stack regardless of PATH"
276        );
277    }
278
279    fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack {
280        Stack {
281            trunk: trunk.to_string(),
282            diffs: diffs.iter().map(|s| s.to_string()).collect(),
283            current: current.to_string(),
284            parents: HashMap::new(),
285        }
286    }
287
288    #[test]
289    fn empty_input_yields_empty_grouping() {
290        let result = group_by_stack(&[]);
291        assert!(result.groups.is_empty());
292        assert!(result.ungrouped.is_empty());
293    }
294
295    #[test]
296    fn all_none_yields_all_ungrouped() {
297        let stacks: Vec<Option<Stack>> = vec![None, None, None];
298        let result = group_by_stack(&stacks);
299        assert!(result.groups.is_empty());
300        assert_eq!(result.ungrouped, vec![0, 1, 2]);
301    }
302
303    #[test]
304    fn single_stacked_worktree_forms_one_group() {
305        let stacks = vec![Some(stack("main", &["feat-a"], "feat-a"))];
306        let result = group_by_stack(&stacks);
307        assert!(result.ungrouped.is_empty());
308        assert_eq!(result.groups.len(), 1);
309        assert_eq!(result.groups[0].members, vec![0]);
310        assert_eq!(result.groups[0].stack.trunk, "main");
311    }
312
313    #[test]
314    fn two_worktrees_same_stack_collapse_into_one_group_ordered_bottom_to_top() {
315        // Two worktrees in the same 2-branch stack: feat-b is bottom, feat-a is top.
316        // Input idx 0 has current="feat-a" (position 1), input idx 1 has current="feat-b" (position 0).
317        let stacks = vec![
318            Some(stack("main", &["feat-b", "feat-a"], "feat-a")), // top worktree
319            Some(stack("main", &["feat-b", "feat-a"], "feat-b")), // bottom worktree
320        ];
321        let result = group_by_stack(&stacks);
322        assert!(result.ungrouped.is_empty());
323        assert_eq!(result.groups.len(), 1);
324        // Members ordered by branch position: idx 1 (feat-b, pos 0) first, idx 0 (feat-a, pos 1) second.
325        assert_eq!(result.groups[0].members, vec![1, 0]);
326    }
327
328    #[test]
329    fn same_stack_different_branches_vector_order_still_collapses() {
330        // The two Stacks have the same set but different vector orderings — must collapse.
331        let stacks = vec![
332            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
333            Some(stack("main", &["feat-b", "feat-a"], "feat-b")),
334        ];
335        let result = group_by_stack(&stacks);
336        assert_eq!(
337            result.groups.len(),
338            1,
339            "different branch vector order must still collapse"
340        );
341        assert!(result.ungrouped.is_empty());
342    }
343
344    #[test]
345    fn two_distinct_stacks_on_same_trunk_stay_separate() {
346        let stacks = vec![
347            Some(stack("main", &["stack1-a"], "stack1-a")),
348            Some(stack("main", &["stack2-a"], "stack2-a")),
349        ];
350        let result = group_by_stack(&stacks);
351        assert_eq!(result.groups.len(), 2);
352        assert!(result.ungrouped.is_empty());
353    }
354
355    #[test]
356    fn mixed_stacked_and_ungrouped() {
357        let stacks = vec![
358            None, // trunk worktree
359            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
360            None, // another ungrouped
361            Some(stack("main", &["feat-a", "feat-b"], "feat-b")),
362        ];
363        let result = group_by_stack(&stacks);
364        assert_eq!(result.ungrouped, vec![0, 2]);
365        assert_eq!(result.groups.len(), 1);
366        // feat-a is position 0, feat-b is position 1 → member idx 1 (current=feat-a) first, then idx 3
367        assert_eq!(result.groups[0].members, vec![1, 3]);
368    }
369
370    #[test]
371    fn groups_ordered_deterministically_by_trunk_then_branch_set() {
372        let stacks = vec![
373            Some(stack("main", &["z-feat"], "z-feat")),
374            Some(stack("dev", &["d-feat"], "d-feat")),
375            Some(stack("main", &["a-feat"], "a-feat")),
376        ];
377        let result = group_by_stack(&stacks);
378        assert_eq!(result.groups.len(), 3);
379        // Ordered: (dev, [d-feat]), (main, [a-feat]), (main, [z-feat])
380        assert_eq!(result.groups[0].stack.trunk, "dev");
381        assert_eq!(result.groups[1].stack.diffs, vec!["a-feat"]);
382        assert_eq!(result.groups[2].stack.diffs, vec!["z-feat"]);
383    }
384}