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 `gh stack`, plus
11//!   metadata-less git-inference via [`StackModel::Git`]; future: branchless, sapling)
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//! ## gh-stack (v1)
29//!
30//! Stack metadata is read from the `gh stack` extension's own JSON file — canonical at
31//! `<common-dir>/gh-stack`, with a degraded union fallback for unlinked per-worktree copies.
32//! See `stack/gh_stack.rs`'s module docs for the read order, the dedupe rule, and why a
33//! truncated read is tolerated rather than fatal (the opposite of Graphite's rule below).
34//!
35//! ## Git-inference (v1)
36//!
37//! [`StackModel::Git`] covers repositories with no stack tool at all: [`enumerate_stacks`] and
38//! [`current_stack`] treat it exactly like [`StackModel::None`] (no branch-level topology to
39//! report), but [`crate::assemble_changesets`] gives it meaning by walking `upstream..HEAD`,
40//! emitting one changeset per commit. It exists purely as an assembly-time model — see
41//! [`StackModel::detect`] for why it is never auto-detected.
42//!
43//! ## Cross-worktree grouping
44//!
45//! [`group_by_stack`] partitions a parallel slice of [`Option<Stack>`] values (one per worktree)
46//! into [`StackGrouping`]: a list of [`StackGroup`]s (each covering one connected stack) plus an
47//! `ungrouped` list for trunk/untracked worktrees. The grouping key is `(trunk, sorted diff
48//! set)`, so two worktrees in the same connected stack always collapse into one group regardless
49//! of the [`Stack::diffs`] vector order returned by [`current_stack`].
50
51pub(crate) mod gh_stack;
52pub(crate) mod graphite;
53pub(crate) mod metadata;
54
55use std::collections::{BTreeMap, HashMap};
56
57use git2::Repository;
58
59use crate::error::Result;
60
61/// Which stacked-diff tool is managing stacks in this repository.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum StackModel {
64    /// No stack tool; today's branch-flat behavior.
65    None,
66    /// Graphite (`gt`) manages stacks via `refs/branch-metadata/*`.
67    Graphite,
68    /// `gh stack` (the `github/gh-stack` extension) manages stacks via a JSON file. See
69    /// `stack/gh_stack.rs`'s module docs for the canonical-file-plus-symlinks model.
70    GhStack,
71    /// No stack-metadata tool; changesets are inferred purely from git, one per commit in
72    /// `upstream..HEAD`. Unlike [`StackModel::Graphite`]/[`StackModel::GhStack`], this carries
73    /// no branch-level stack topology: [`enumerate_stacks`] and [`current_stack`] treat it as
74    /// flat (same as [`StackModel::None`]), since there is no metadata to enumerate stacks
75    /// from or to group branches into. Only [`crate::assemble_changesets`] (M1 changeset
76    /// assembly) gives this variant meaning, walking `upstream..HEAD` per-commit.
77    ///
78    /// Not reachable via [`StackModel::detect`] — see the module docs' Default-on-behavior
79    /// note on why auto-detection never resolves to `Git`.
80    Git,
81}
82
83impl StackModel {
84    /// Auto-detect the active stack model from the repository environment.
85    ///
86    /// Returns [`StackModel::Graphite`] when the repo has been initialized with `gt init`
87    /// (`.graphite_repo_config` or `.graphite_metadata.db` exists). Otherwise returns
88    /// [`StackModel::None`].
89    ///
90    /// Deliberately does NOT consult [`graphite::detect_gt`]: the repo's own metadata is the
91    /// ground truth for "is this a Graphite stack," and reading it is pure libgit2 (see the
92    /// module docs — no `gt` process is needed for detection or visualization). Gating on the
93    /// binary's presence would report `None` for a genuine Graphite stack whenever `gt` happens
94    /// to be missing from PATH, silently emptying the review TUI's stack. Whether `gt` can be
95    /// *executed* is a separate question, and belongs to the call sites that execute it.
96    ///
97    /// Never returns [`StackModel::Git`]: auto-detection only distinguishes "a stack tool is
98    /// active" from "no stack tool," since CLI routing treats any non-`None` model as
99    /// stack-active. Auto-resolving to `Git` for every repository with an upstream-tracking
100    /// branch would silently flip that routing for nearly every user. `Git` is reachable via
101    /// explicit `workon.stackModel = git` config, or a caller mapping `None` to `Git` before
102    /// calling [`crate::assemble_changesets`] (the review crate does this from M3 onward).
103    ///
104    /// **Graphite wins** when both tools' artifacts are present: `.graphite_repo_config`
105    /// comes from an explicit, repo-wide `gt init`, while a `gh-stack` file can appear as a
106    /// side effect of one `gh stack add` run in one worktree. The more deliberate,
107    /// repo-scoped signal wins, so no repo that resolves to `Graphite` today can silently flip
108    /// to `GhStack` just because someone tried the other tool once. The escape hatch is an
109    /// explicit `workon.stackModel = gh-stack`.
110    pub fn detect(repo: &Repository) -> Self {
111        if graphite::is_graphite_repo(repo) {
112            Self::Graphite
113        } else if gh_stack::is_gh_stack_repo(repo) {
114            Self::GhStack
115        } else {
116            Self::None
117        }
118    }
119}
120
121/// How worktrees map to stacks.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Granularity {
124    /// One worktree hosts an entire stack. The user navigates between branches inside it
125    /// using the stack tool's own commands (e.g. `gt up` / `gt down`).
126    Stack,
127}
128
129/// A stack of branches rooted at a trunk branch.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct Stack {
132    /// The trunk branch this stack is rooted on (e.g., `"main"`).
133    pub trunk: String,
134    /// All non-trunk diffs (branches) in the stack, in BFS order from bottom to top.
135    pub diffs: Vec<String>,
136    /// The branch that is currently HEAD in the worktree.
137    pub current: String,
138    /// Parent map for the diffs in this stack: `diff → parent_branch`.
139    ///
140    /// The parent may be another diff or the trunk itself. Only covers diffs in
141    /// [`Stack::diffs`]; the trunk's own parent is not recorded. Used to reconstruct
142    /// the branching tree structure (a flat [`diffs`] list cannot represent forks).
143    pub parents: HashMap<String, String>,
144    /// Provider-assigned stack number (e.g. gh-stack's `stacks[].number`), for display only.
145    ///
146    /// This is **display metadata, never an identity key** — [`group_by_stack`] keys on
147    /// `(trunk, sorted diff set)` and does not consult this field. Always `None` for
148    /// [`StackModel::Graphite`] and [`StackModel::Git`], which have no numbering concept.
149    pub number: Option<u64>,
150}
151
152/// Return all stacks present in metadata, one per connected component.
153///
154/// Each returned [`Stack`] corresponds to one potential [`StackGroup`] — the same `(trunk,
155/// sorted diffs)` key used by [`group_by_stack`]. Used by the `list` command to surface
156/// stacks that have no checked-out worktrees.
157pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
158    match model {
159        StackModel::None => Ok(vec![]),
160        StackModel::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
161        StackModel::GhStack => gh_stack::enumerate_stacks(repo).map_err(Into::into),
162        // Git-inference has no branch-level stack topology to enumerate — flat, like None.
163        StackModel::Git => Ok(vec![]),
164    }
165}
166
167/// Return the stack for the worktree whose HEAD is `head_branch`, or `None` if the branch
168/// is not part of a tracked stack under `model`.
169///
170/// The returned [`Stack`] includes all branches reachable from the same stack root, not just
171/// the ancestors of `head_branch`, so branching stacks are fully represented.
172pub fn current_stack(
173    repo: &Repository,
174    head_branch: &str,
175    model: StackModel,
176) -> Result<Option<Stack>> {
177    match model {
178        StackModel::None => Ok(None),
179        StackModel::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
180        StackModel::GhStack => gh_stack::current_stack(repo, head_branch).map_err(Into::into),
181        // Git-inference has no branch-level stack topology — flat, like None.
182        StackModel::Git => Ok(None),
183    }
184}
185
186/// Returns `true` if `gt` is on PATH and this repository has been Graphite-initialized.
187pub fn is_graphite_active(repo: &Repository) -> bool {
188    graphite::detect_gt() && graphite::is_graphite_repo(repo)
189}
190
191/// Return the first trunk branch name from `.graphite_repo_config`, or `None` if
192/// the file is missing, unparseable, or contains no trunk entries.
193///
194/// Use this when you need to pass `--parent <trunk>` to `gt track` and want to
195/// avoid hardcoding `"main"`. Returns `None` rather than a hardcoded fallback so
196/// callers can omit `--parent` entirely and let `gt` infer when the trunk is unknown.
197pub fn graphite_trunk(repo: &Repository) -> Option<String> {
198    graphite::graphite_trunk(repo)
199}
200
201/// Plant `gh-stack`/`gh-stack.lock` symlinks for `worktree_name`, pointing at the canonical
202/// `<common-dir>/gh-stack` store. Idempotent; never replaces a real file — see
203/// [`migrate_worktree`] for that. Callers gate this on `StackModel::GhStack` themselves (see
204/// `workon new`'s hook); planting is harmless under any other model, but pointless.
205pub fn link_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
206    gh_stack::link_worktree(repo, worktree_name).map_err(Into::into)
207}
208
209/// Append `branch` to the canonical gh-stack file's stack that currently ends at
210/// `base_branch`. Callers gate this on `StackModel::GhStack` themselves (see `workon new`'s
211/// hook), matching [`link_worktree`]. See `stack/gh_stack.rs`'s `register_branch` for the
212/// locking, CAS, and target-selection rules.
213pub fn register_branch(repo: &Repository, branch: &str, base_branch: &str) -> Result<()> {
214    gh_stack::register_branch(repo, branch, base_branch).map_err(Into::into)
215}
216
217/// Merge `worktree_name`'s real `gh-stack` file into canonical, then replace it with a
218/// symlink, leaving `gh-stack.bak` behind. Reachable only from `doctor --fix` — never
219/// automatically. See `stack/gh_stack.rs`'s module docs for the shared-canonical-file model.
220pub fn migrate_worktree(repo: &Repository, worktree_name: &str) -> Result<()> {
221    gh_stack::migrate_worktree(repo, worktree_name).map_err(Into::into)
222}
223
224/// `true` if the repository has been Graphite-initialized (`.graphite_repo_config` or
225/// `.graphite_metadata.db` exists), independent of whether `gt` is on PATH. Unlike
226/// [`is_graphite_active`], this doesn't gate on `gt`'s presence — `doctor`'s
227/// `BothStackToolsDetected` check needs to know whether the repo's *metadata* says Graphite,
228/// not whether `gt` is currently executable.
229pub fn is_graphite_repo(repo: &Repository) -> bool {
230    graphite::is_graphite_repo(repo)
231}
232
233/// `true` if this repository has a gh-stack file anywhere workon knows to look — canonical
234/// or an unlinked worktree file. Used by `doctor`'s `GhStackNotInitialized` and
235/// `BothStackToolsDetected` checks.
236pub fn is_gh_stack_repo(repo: &Repository) -> bool {
237    gh_stack::is_gh_stack_repo(repo)
238}
239
240/// Per-worktree gh-stack link status, for `doctor`'s `GhStackWorktreeNotLinked` check.
241pub use gh_stack::LinkStatus as GhStackLinkStatus;
242
243/// Compute [`GhStackLinkStatus`] for `worktree_name`'s `gh-stack` admin-dir path.
244pub fn gh_stack_worktree_link_status(repo: &Repository, worktree_name: &str) -> GhStackLinkStatus {
245    gh_stack::worktree_link_status(repo, worktree_name)
246}
247
248/// gh-stack files (canonical + unlinked worktree copies) that exist but fail to parse or use
249/// an unsupported schema version, as `(path, reason)` pairs. For `doctor`'s
250/// `GhStackFileUnreadable` check.
251pub fn gh_stack_readability_errors(repo: &Repository) -> Vec<(std::path::PathBuf, String)> {
252    gh_stack::readability_errors(repo)
253        .into_iter()
254        .map(|(path, err)| (path, err.to_string()))
255        .collect()
256}
257
258/// Stack numbers that appear in more than one gh-stack source, only reachable in degraded
259/// (union-read) mode. For `doctor`'s `GhStackDivergentStacks` check.
260pub fn gh_stack_divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
261    gh_stack::divergent_stack_numbers(repo)
262}
263
264/// One group of worktrees that share a connected stack, identified by trunk + diff set.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct StackGroup {
267    /// Representative stack (trunk + diffs in BFS bottom→top order) for the group.
268    pub stack: Stack,
269    /// Indices into the caller's worktree slice for members of this stack, ordered by
270    /// the member branch's position in `stack.diffs` (bottom→top). Members whose
271    /// branch isn't found in `stack.diffs` sort last, in original input order.
272    pub members: Vec<usize>,
273}
274
275/// Result of partitioning a worktree slice by stacks via [`group_by_stack`].
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct StackGrouping {
278    /// Groups of worktrees sharing a connected stack, ordered by `(trunk, sorted branch set)`.
279    pub groups: Vec<StackGroup>,
280    /// Indices of worktrees with no stack (trunk branches, untracked branches), in input order.
281    pub ungrouped: Vec<usize>,
282}
283
284/// Partition a slice of optional stack descriptors into groups sharing the same connected stack.
285///
286/// The grouping key is `(trunk, sorted diff set)`, so worktrees in the same connected stack
287/// always collapse into one group regardless of the [`Stack::diffs`] vector order returned
288/// by [`current_stack`]. Members within each group are ordered by their branch's position in
289/// the representative stack's `diffs` list (bottom→top).
290///
291/// Groups are returned in deterministic order: sorted by `(trunk, sorted branch set)`.
292///
293/// When all entries are `None` (stack model inactive or `--no-stack`), `groups` is empty and
294/// every index appears in `ungrouped` — callers get identical flat-list behavior.
295pub fn group_by_stack(stacks: &[Option<Stack>]) -> StackGrouping {
296    // BTreeMap provides sorted iteration: (trunk, branch_set) order automatically.
297    let mut map: BTreeMap<(String, Vec<String>), (Stack, Vec<usize>)> = BTreeMap::new();
298    let mut ungrouped: Vec<usize> = Vec::new();
299
300    for (i, opt) in stacks.iter().enumerate() {
301        match opt {
302            None => ungrouped.push(i),
303            Some(stack) => {
304                let mut key_branches = stack.diffs.clone();
305                key_branches.sort();
306                let key = (stack.trunk.clone(), key_branches);
307                let entry = map
308                    .entry(key)
309                    .or_insert_with(|| (stack.clone(), Vec::new()));
310                entry.1.push(i);
311            }
312        }
313    }
314
315    let groups = map
316        .into_values()
317        .map(|(rep_stack, mut members)| {
318            // Sort members by their branch's position in rep_stack.diffs (bottom→top).
319            members.sort_by_key(|&idx| {
320                let branch = stacks[idx]
321                    .as_ref()
322                    .map(|s| s.current.as_str())
323                    .unwrap_or("");
324                rep_stack
325                    .diffs
326                    .iter()
327                    .position(|b| b == branch)
328                    .unwrap_or(usize::MAX)
329            });
330            StackGroup {
331                stack: rep_stack,
332                members,
333            }
334        })
335        .collect();
336
337    StackGrouping { groups, ungrouped }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use git_workon_fixture::prelude::*;
344
345    /// `detect` must resolve `Graphite` from repo metadata alone, with no dependency on whether
346    /// `gt` is installed on the host running the tests.
347    ///
348    /// The "gt absent" half cannot be forced from inside this process: it would mean mutating
349    /// `PATH`, which is process-global (`unsafe` under Rust 2024) and would race every other test
350    /// in this binary. Only the CLI suite can scrub `PATH` hermetically, by passing it to a child
351    /// process (`git-workon/tests/suite/new.rs`'s `path_without_gt_new`). So the guard here is
352    /// environmental rather than hermetic — CI has no `gt`, so a reintroduced `detect_gt()` gate
353    /// turns this red there. Its value is making that failure say "detection must not require gt"
354    /// instead of surfacing as a handful of unrelated review-TUI changeset-count assertions.
355    #[test]
356    fn detect_resolves_graphite_from_repo_metadata_without_requiring_the_gt_binary() {
357        let fixture = FixtureBuilder::new()
358            .graphite_config(&["main"])
359            .branch_metadata("a", "main")
360            .build()
361            .unwrap();
362        let repo = fixture.repo().unwrap();
363
364        assert_eq!(
365            StackModel::detect(repo),
366            StackModel::Graphite,
367            "a gt-initialized repo is a Graphite stack regardless of PATH"
368        );
369    }
370
371    #[test]
372    fn detect_resolves_gh_stack_from_canonical_file() {
373        let fixture = FixtureBuilder::new()
374            .gh_stack(None, 1, "main", &["feat-a"])
375            .build()
376            .unwrap();
377        let repo = fixture.repo().unwrap();
378
379        assert_eq!(StackModel::detect(repo), StackModel::GhStack);
380    }
381
382    #[test]
383    fn detect_prefers_graphite_when_both_tools_artifacts_are_present() {
384        let fixture = FixtureBuilder::new()
385            .graphite_config(&["main"])
386            .branch_metadata("a", "main")
387            .gh_stack(None, 1, "main", &["feat-a"])
388            .build()
389            .unwrap();
390        let repo = fixture.repo().unwrap();
391
392        assert_eq!(
393            StackModel::detect(repo),
394            StackModel::Graphite,
395            "Graphite's repo-wide gt init must win over a gh-stack file appearing alongside it"
396        );
397    }
398
399    fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack {
400        Stack {
401            trunk: trunk.to_string(),
402            diffs: diffs.iter().map(|s| s.to_string()).collect(),
403            current: current.to_string(),
404            parents: HashMap::new(),
405            number: None,
406        }
407    }
408
409    #[test]
410    fn empty_input_yields_empty_grouping() {
411        let result = group_by_stack(&[]);
412        assert!(result.groups.is_empty());
413        assert!(result.ungrouped.is_empty());
414    }
415
416    #[test]
417    fn all_none_yields_all_ungrouped() {
418        let stacks: Vec<Option<Stack>> = vec![None, None, None];
419        let result = group_by_stack(&stacks);
420        assert!(result.groups.is_empty());
421        assert_eq!(result.ungrouped, vec![0, 1, 2]);
422    }
423
424    #[test]
425    fn single_stacked_worktree_forms_one_group() {
426        let stacks = vec![Some(stack("main", &["feat-a"], "feat-a"))];
427        let result = group_by_stack(&stacks);
428        assert!(result.ungrouped.is_empty());
429        assert_eq!(result.groups.len(), 1);
430        assert_eq!(result.groups[0].members, vec![0]);
431        assert_eq!(result.groups[0].stack.trunk, "main");
432    }
433
434    #[test]
435    fn two_worktrees_same_stack_collapse_into_one_group_ordered_bottom_to_top() {
436        // Two worktrees in the same 2-branch stack: feat-b is bottom, feat-a is top.
437        // Input idx 0 has current="feat-a" (position 1), input idx 1 has current="feat-b" (position 0).
438        let stacks = vec![
439            Some(stack("main", &["feat-b", "feat-a"], "feat-a")), // top worktree
440            Some(stack("main", &["feat-b", "feat-a"], "feat-b")), // bottom worktree
441        ];
442        let result = group_by_stack(&stacks);
443        assert!(result.ungrouped.is_empty());
444        assert_eq!(result.groups.len(), 1);
445        // Members ordered by branch position: idx 1 (feat-b, pos 0) first, idx 0 (feat-a, pos 1) second.
446        assert_eq!(result.groups[0].members, vec![1, 0]);
447    }
448
449    #[test]
450    fn same_stack_different_branches_vector_order_still_collapses() {
451        // The two Stacks have the same set but different vector orderings — must collapse.
452        let stacks = vec![
453            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
454            Some(stack("main", &["feat-b", "feat-a"], "feat-b")),
455        ];
456        let result = group_by_stack(&stacks);
457        assert_eq!(
458            result.groups.len(),
459            1,
460            "different branch vector order must still collapse"
461        );
462        assert!(result.ungrouped.is_empty());
463    }
464
465    #[test]
466    fn two_distinct_stacks_on_same_trunk_stay_separate() {
467        let stacks = vec![
468            Some(stack("main", &["stack1-a"], "stack1-a")),
469            Some(stack("main", &["stack2-a"], "stack2-a")),
470        ];
471        let result = group_by_stack(&stacks);
472        assert_eq!(result.groups.len(), 2);
473        assert!(result.ungrouped.is_empty());
474    }
475
476    #[test]
477    fn mixed_stacked_and_ungrouped() {
478        let stacks = vec![
479            None, // trunk worktree
480            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
481            None, // another ungrouped
482            Some(stack("main", &["feat-a", "feat-b"], "feat-b")),
483        ];
484        let result = group_by_stack(&stacks);
485        assert_eq!(result.ungrouped, vec![0, 2]);
486        assert_eq!(result.groups.len(), 1);
487        // feat-a is position 0, feat-b is position 1 → member idx 1 (current=feat-a) first, then idx 3
488        assert_eq!(result.groups[0].members, vec![1, 3]);
489    }
490
491    #[test]
492    fn groups_ordered_deterministically_by_trunk_then_branch_set() {
493        let stacks = vec![
494            Some(stack("main", &["z-feat"], "z-feat")),
495            Some(stack("dev", &["d-feat"], "d-feat")),
496            Some(stack("main", &["a-feat"], "a-feat")),
497        ];
498        let result = group_by_stack(&stacks);
499        assert_eq!(result.groups.len(), 3);
500        // Ordered: (dev, [d-feat]), (main, [a-feat]), (main, [z-feat])
501        assert_eq!(result.groups[0].stack.trunk, "dev");
502        assert_eq!(result.groups[1].stack.diffs, vec!["a-feat"]);
503        assert_eq!(result.groups[2].stack.diffs, vec!["z-feat"]);
504    }
505}