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`, [`StackModel::Mixed`]
11//!   when both have live branches, plus metadata-less git-inference via [`StackModel::Git`];
12//!   future: branchless, sapling)
13//! - [`Granularity`] — how worktrees map to stacks (v1: [`Granularity::Stack`], one per stack)
14//!
15//! ## Default-on behavior
16//!
17//! When `workon.stackModel` resolves to anything other than [`StackModel::None`] (by explicit
18//! config or auto-detection), every command that has a meaningful stack-aware variant uses it
19//! by default. A `--no-stack` CLI flag (global across subcommands) downgrades any single
20//! invocation back to branch-flat behavior.
21//!
22//! ## Graphite (v1)
23//!
24//! Stack metadata is read directly from `refs/branch-metadata/*` git refs — blobs containing
25//! JSON written by the `gt` CLI. No `gt` process is needed for detection or visualization.
26//! `gt track` is invoked only when registering a new branch (in `workon new` when creating a
27//! fork off a stack-worktree's branch).
28//!
29//! ## gh-stack (v1)
30//!
31//! Stack metadata is read from the `gh stack` extension's own JSON file — canonical at
32//! `<common-dir>/gh-stack`, with a degraded union fallback for unlinked per-worktree copies.
33//! See `stack/gh_stack.rs`'s module docs for the read order, the dedupe rule, and why a
34//! truncated read is tolerated rather than fatal (the opposite of Graphite's rule below).
35//!
36//! ## Git-inference (v1)
37//!
38//! [`StackModel::Git`] covers repositories with no stack tool at all: [`enumerate_stacks`] and
39//! [`current_stack`] treat it exactly like [`StackModel::None`] (no branch-level topology to
40//! report), but [`crate::assemble_changesets`] gives it meaning by walking `upstream..HEAD`,
41//! emitting one changeset per commit. It exists purely as an assembly-time model — see
42//! [`StackModel::detect`] for why it is never auto-detected.
43//!
44//! ## Cross-worktree grouping
45//!
46//! [`group_by_stack`] partitions a parallel slice of [`Option<Stack>`] values (one per worktree)
47//! into [`StackGrouping`]: a list of [`StackGroup`]s (each covering one connected stack) plus an
48//! `ungrouped` list for trunk/untracked worktrees. The grouping key is `(trunk, sorted diff
49//! set)`, so two worktrees in the same connected stack always collapse into one group regardless
50//! of the [`Stack::diffs`] vector order returned by [`current_stack`].
51
52pub(crate) mod gh_stack;
53pub(crate) mod graphite;
54pub(crate) mod metadata;
55
56use std::collections::{BTreeMap, HashMap, HashSet};
57
58use git2::Repository;
59
60use crate::error::Result;
61
62/// A concrete stacked-diff tool. [`StackModel::Mixed`] names one as primary, the other as
63/// fallback.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum StackProvider {
66    /// Graphite (`gt`), read via `stack/graphite.rs`.
67    Graphite,
68    /// `gh stack`, read via `stack/gh_stack.rs`.
69    GhStack,
70}
71
72/// Which stacked-diff tool is managing stacks in this repository.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum StackModel {
75    /// No stack tool; today's branch-flat behavior.
76    None,
77    /// Graphite (`gt`) manages stacks via `refs/branch-metadata/*`.
78    Graphite,
79    /// `gh stack` (the `github/gh-stack` extension) manages stacks via a JSON file. See
80    /// `stack/gh_stack.rs`'s module docs for the shared-catalog model.
81    GhStack,
82    /// Both Graphite and gh-stack have ref-backed tracked branches in this repository.
83    /// `primary` is consulted first by [`current_stack`], [`enumerate_stacks`], and
84    /// [`crate::assemble_changesets`]; the other provider answers only when `primary` has no
85    /// row for the branch in question. Only reachable via `"auto"` (or unset) config — an
86    /// explicit `workon.stackModel = graphite`/`gh-stack` stays strict, never falling back to
87    /// the other provider even when it also has live branches. See [`StackModel::detect`].
88    Mixed { primary: StackProvider },
89    /// No stack-metadata tool; changesets are inferred purely from git, one per commit in
90    /// `upstream..HEAD`. Unlike [`StackModel::Graphite`]/[`StackModel::GhStack`], this carries
91    /// no branch-level stack topology: [`enumerate_stacks`] and [`current_stack`] treat it as
92    /// flat (same as [`StackModel::None`]), since there is no metadata to enumerate stacks
93    /// from or to group branches into. Only [`crate::assemble_changesets`] (M1 changeset
94    /// assembly) gives this variant meaning, walking `upstream..HEAD` per-commit.
95    ///
96    /// Not reachable via [`StackModel::detect`] — see the module docs' Default-on-behavior
97    /// note on why auto-detection never resolves to `Git`.
98    Git,
99}
100
101impl StackModel {
102    /// Auto-detect the active stack model from the repository environment.
103    ///
104    /// Returns [`StackModel::Graphite`] when the repo has been initialized with `gt init`
105    /// (`.graphite_repo_config` or `.graphite_metadata.db` exists). Otherwise returns
106    /// [`StackModel::None`].
107    ///
108    /// Deliberately does NOT consult [`graphite::detect_gt`]: the repo's own metadata is the
109    /// ground truth for "is this a Graphite stack," and reading it is pure libgit2 (see the
110    /// module docs — no `gt` process is needed for detection or visualization). Gating on the
111    /// binary's presence would report `None` for a genuine Graphite stack whenever `gt` happens
112    /// to be missing from PATH, silently emptying the review TUI's stack. Whether `gt` can be
113    /// *executed* is a separate question, and belongs to the call sites that execute it.
114    ///
115    /// Never returns [`StackModel::Git`]: auto-detection only distinguishes "a stack tool is
116    /// active" from "no stack tool," since CLI routing treats any non-`None` model as
117    /// stack-active. Auto-resolving to `Git` for every repository with an upstream-tracking
118    /// branch would silently flip that routing for nearly every user. `Git` is reachable via
119    /// explicit `workon.stackModel = git` config, or a caller mapping `None` to `Git` before
120    /// calling [`crate::assemble_changesets`] (the review crate does this from M3 onward).
121    ///
122    /// **When both tools' artifacts are present**, detection tie-breaks on *live* metadata —
123    /// whether each provider has at least one ref-backed tracked branch (see
124    /// [`graphite::has_live_branches`]/[`gh_stack::has_live_branches`]) — rather than always
125    /// preferring one provider:
126    ///
127    /// - Both live → [`StackModel::Mixed`] with gh-stack as primary: GitHub's native stack
128    ///   support is the more deliberate signal now that it exists, so it answers first, but
129    ///   neither provider's tracked branches are hidden.
130    /// - Only one live → that provider, strict (not `Mixed`): the other's artifacts are stale
131    ///   (e.g. a lingering `.graphite_repo_config` from a repo that migrated to gh-stack), so
132    ///   there is nothing to fall back to.
133    /// - Neither live → `GhStack`: an ambiguous, doubly-stale state that `doctor`'s
134    ///   `BothStackToolsDetected` check explains rather than detection resolving further.
135    ///
136    /// A single artifact present (no ambiguity) skips the liveness read entirely and returns
137    /// that provider, matching today's behavior. The escape hatch out of `Mixed` — or out of a
138    /// tie-break you disagree with — is an explicit `workon.stackModel = graphite`/`gh-stack`,
139    /// which is strict and never reachable via `"auto"`.
140    pub fn detect(repo: &Repository) -> Self {
141        let graphite_artifacts = graphite::is_graphite_repo(repo);
142        let gh_artifacts = gh_stack::is_gh_stack_repo(repo);
143
144        match (graphite_artifacts, gh_artifacts) {
145            (false, false) => Self::None,
146            (true, false) => Self::Graphite,
147            (false, true) => Self::GhStack,
148            (true, true) => {
149                let graphite_live = graphite::has_live_branches(repo);
150                let gh_stack_live = gh_stack::has_live_branches(repo);
151                match (graphite_live, gh_stack_live) {
152                    (true, true) => Self::Mixed {
153                        primary: StackProvider::GhStack,
154                    },
155                    (true, false) => Self::Graphite,
156                    (false, true) => Self::GhStack,
157                    (false, false) => Self::GhStack,
158                }
159            }
160        }
161    }
162
163    /// Providers to consult, in fallback order. `Graphite`/`GhStack` consult only themselves
164    /// (strict, no fallback); `Mixed` consults `primary` first, then the other; `None`/`Git`
165    /// have no provider to consult.
166    pub fn providers(self) -> impl Iterator<Item = StackProvider> {
167        let (first, second) = match self {
168            Self::Graphite => (Some(StackProvider::Graphite), None),
169            Self::GhStack => (Some(StackProvider::GhStack), None),
170            Self::Mixed { primary } => {
171                let other = match primary {
172                    StackProvider::Graphite => StackProvider::GhStack,
173                    StackProvider::GhStack => StackProvider::Graphite,
174                };
175                (Some(primary), Some(other))
176            }
177            Self::None | Self::Git => (None, None),
178        };
179        first.into_iter().chain(second)
180    }
181}
182
183/// How worktrees map to stacks.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum Granularity {
186    /// One worktree hosts an entire stack. The user navigates between branches inside it
187    /// using the stack tool's own commands (e.g. `gt up` / `gt down`).
188    Stack,
189}
190
191/// A stack of branches rooted at a trunk branch.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Stack {
194    /// The trunk branch this stack is rooted on (e.g., `"main"`).
195    pub trunk: String,
196    /// All non-trunk diffs (branches) in the stack, in BFS order from bottom to top.
197    pub diffs: Vec<String>,
198    /// The branch that is currently HEAD in the worktree.
199    pub current: String,
200    /// Parent map for the diffs in this stack: `diff → parent_branch`.
201    ///
202    /// The parent may be another diff or the trunk itself. Only covers diffs in
203    /// [`Stack::diffs`]; the trunk's own parent is not recorded. Used to reconstruct
204    /// the branching tree structure (a flat [`diffs`] list cannot represent forks).
205    pub parents: HashMap<String, String>,
206    /// Provider-assigned stack number (e.g. gh-stack's `stacks[].number`), for display only.
207    ///
208    /// This is **display metadata, never an identity key** — [`group_by_stack`] keys on
209    /// `(trunk, sorted diff set)` and does not consult this field. Always `None` for
210    /// [`StackModel::Graphite`] and [`StackModel::Git`], which have no numbering concept.
211    pub number: Option<u64>,
212    /// Member branches (a subset of [`Stack::diffs`]) whose PR has merged, per
213    /// `BranchMetadata::merged`. Always empty for [`StackModel::Graphite`] — see
214    /// [`graphite::read_branch_metadata`]'s docs for why merged state isn't observable there.
215    pub merged: HashSet<String>,
216}
217
218/// Dispatch [`enumerate_stacks`] to one concrete provider's implementation.
219fn provider_enumerate_stacks(repo: &Repository, provider: StackProvider) -> Result<Vec<Stack>> {
220    match provider {
221        StackProvider::Graphite => graphite::enumerate_stacks(repo).map_err(Into::into),
222        StackProvider::GhStack => gh_stack::enumerate_stacks(repo).map_err(Into::into),
223    }
224}
225
226/// Dispatch [`current_stack`] to one concrete provider's implementation.
227fn provider_current_stack(
228    repo: &Repository,
229    head_branch: &str,
230    provider: StackProvider,
231) -> Result<Option<Stack>> {
232    match provider {
233        StackProvider::Graphite => graphite::current_stack(repo, head_branch).map_err(Into::into),
234        StackProvider::GhStack => gh_stack::current_stack(repo, head_branch).map_err(Into::into),
235    }
236}
237
238/// Return all stacks present in metadata, one per connected component.
239///
240/// Each returned [`Stack`] corresponds to one potential [`StackGroup`] — the same `(trunk,
241/// sorted diffs)` key used by [`group_by_stack`]. Used by the `list` command to surface
242/// stacks that have no checked-out worktrees.
243///
244/// Under [`StackModel::Mixed`], returns the primary provider's stacks followed by the
245/// secondary's, excluding any secondary stack that shares a branch with a primary stack — a
246/// branch belongs to one place in the tree, and the primary's placement always wins (the CLI's
247/// `display::build_tree` merges stacks per trunk with a `visited`-based dedupe that would
248/// otherwise drop the shared branch non-deterministically).
249pub fn enumerate_stacks(repo: &Repository, model: StackModel) -> Result<Vec<Stack>> {
250    match model {
251        StackModel::None => Ok(vec![]),
252        // Git-inference has no branch-level stack topology to enumerate — flat, like None.
253        StackModel::Git => Ok(vec![]),
254        StackModel::Graphite => provider_enumerate_stacks(repo, StackProvider::Graphite),
255        StackModel::GhStack => provider_enumerate_stacks(repo, StackProvider::GhStack),
256        StackModel::Mixed { .. } => {
257            let mut providers = model.providers();
258            let primary = providers.next().expect("Mixed always has a primary");
259            let secondary = providers.next().expect("Mixed always has a secondary");
260            let primary_stacks = provider_enumerate_stacks(repo, primary)?;
261            let claimed: std::collections::HashSet<String> = primary_stacks
262                .iter()
263                .flat_map(|s| s.diffs.iter().cloned())
264                .collect();
265            let secondary_stacks = provider_enumerate_stacks(repo, secondary)?
266                .into_iter()
267                .filter(|s| !s.diffs.iter().any(|b| claimed.contains(b)));
268            Ok(primary_stacks.into_iter().chain(secondary_stacks).collect())
269        }
270    }
271}
272
273/// Return the stack for the worktree whose HEAD is `head_branch`, or `None` if the branch
274/// is not part of a tracked stack under `model`.
275///
276/// The returned [`Stack`] includes all branches reachable from the same stack root, not just
277/// the ancestors of `head_branch`, so branching stacks are fully represented.
278///
279/// Under [`StackModel::Mixed`], consults [`StackModel::providers`] in order and returns the
280/// first `Some`: the primary provider's row for `head_branch` if it has one, else the
281/// secondary's. An error from either provider propagates immediately rather than being
282/// swallowed to reach the fallback — a corrupt store is a real failure, not "no row here."
283pub fn current_stack(
284    repo: &Repository,
285    head_branch: &str,
286    model: StackModel,
287) -> Result<Option<Stack>> {
288    match model {
289        StackModel::None => Ok(None),
290        // Git-inference has no branch-level stack topology — flat, like None.
291        StackModel::Git => Ok(None),
292        _ => {
293            for provider in model.providers() {
294                if let Some(stack) = provider_current_stack(repo, head_branch, provider)? {
295                    return Ok(Some(stack));
296                }
297            }
298            Ok(None)
299        }
300    }
301}
302
303/// Returns `true` if `gt` is on PATH and this repository has been Graphite-initialized.
304pub fn is_graphite_active(repo: &Repository) -> bool {
305    graphite::detect_gt() && graphite::is_graphite_repo(repo)
306}
307
308/// Return the first trunk branch name from `.graphite_repo_config`, or `None` if
309/// the file is missing, unparseable, or contains no trunk entries.
310///
311/// Use this when you need to pass `--parent <trunk>` to `gt track` and want to
312/// avoid hardcoding `"main"`. Returns `None` rather than a hardcoded fallback so
313/// callers can omit `--parent` entirely and let `gt` infer when the trunk is unknown.
314pub fn graphite_trunk(repo: &Repository) -> Option<String> {
315    graphite::graphite_trunk(repo)
316}
317
318/// Append `branch` to the canonical gh-stack file's stack that currently ends at
319/// `base_branch`. Callers gate this on `StackModel::GhStack` themselves (see `workon new`'s
320/// hook). See `stack/gh_stack.rs`'s `register_branch` for the
321/// locking, CAS, and target-selection rules.
322pub fn register_branch(repo: &Repository, branch: &str, base_branch: &str) -> Result<()> {
323    gh_stack::register_branch(repo, branch, base_branch).map_err(Into::into)
324}
325
326/// `true` if the repository has been Graphite-initialized (`.graphite_repo_config` or
327/// `.graphite_metadata.db` exists), independent of whether `gt` is on PATH. Unlike
328/// [`is_graphite_active`], this doesn't gate on `gt`'s presence — `doctor`'s
329/// `BothStackToolsDetected` check needs to know whether the repo's *metadata* says Graphite,
330/// not whether `gt` is currently executable.
331pub fn is_graphite_repo(repo: &Repository) -> bool {
332    graphite::is_graphite_repo(repo)
333}
334
335/// `true` if this repository has a gh-stack file anywhere workon knows to look — canonical
336/// or an unlinked worktree file. Used by `doctor`'s `GhStackNotInitialized` and
337/// `BothStackToolsDetected` checks.
338pub fn is_gh_stack_repo(repo: &Repository) -> bool {
339    gh_stack::is_gh_stack_repo(repo)
340}
341
342/// `true` if `provider` has at least one ref-backed tracked branch in this repository — see
343/// [`graphite::has_live_branches`]/[`gh_stack::has_live_branches`] for the definition. Used by
344/// [`StackModel::detect`]'s tie-break, and by `list`'s pin hint and `doctor`'s
345/// `StackModelPinHidesLiveBranches` check to tell whether an explicit `workon.stackModel` pin
346/// is hiding the other provider's tracked branches.
347pub fn provider_has_live_branches(repo: &Repository, provider: StackProvider) -> bool {
348    match provider {
349        StackProvider::Graphite => graphite::has_live_branches(repo),
350        StackProvider::GhStack => gh_stack::has_live_branches(repo),
351    }
352}
353
354/// What a worktree's admin dir holds that gh-stack >=0.2 cares about (symlinks workon planted,
355/// a legacy catalog, recovery records), for `doctor`'s per-worktree gh-stack checks.
356pub use gh_stack::WorktreeGhStackState as GhStackWorktreeState;
357
358/// Compute [`GhStackWorktreeState`] for `worktree_name`'s admin dir.
359pub fn gh_stack_worktree_state(repo: &Repository, worktree_name: &str) -> GhStackWorktreeState {
360    gh_stack::worktree_state(repo, worktree_name)
361}
362
363/// Remove the `gh-stack`/`gh-stack.lock` symlinks workon planted in `worktree_name`'s admin dir,
364/// returning the file names removed. Real files are never touched.
365pub fn unlink_gh_stack_worktree(
366    repo: &Repository,
367    worktree_name: &str,
368) -> Result<Vec<&'static str>> {
369    gh_stack::unlink_worktree(repo, worktree_name)
370}
371
372/// gh-stack files (canonical + unlinked worktree copies) that exist but fail to parse or use
373/// an unsupported schema version, as `(path, reason)` pairs. For `doctor`'s
374/// `GhStackFileUnreadable` check.
375pub fn gh_stack_readability_errors(repo: &Repository) -> Vec<(std::path::PathBuf, String)> {
376    gh_stack::readability_errors(repo)
377        .into_iter()
378        .map(|(path, err)| (path, err.to_string()))
379        .collect()
380}
381
382/// Stack numbers that appear in more than one gh-stack source, only reachable in degraded
383/// (union-read) mode. For `doctor`'s `GhStackDivergentStacks` check.
384pub fn gh_stack_divergent_stack_numbers(repo: &Repository) -> Vec<u64> {
385    gh_stack::divergent_stack_numbers(repo)
386}
387
388/// One group of worktrees that share a connected stack, identified by trunk + diff set.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct StackGroup {
391    /// Representative stack (trunk + diffs in BFS bottom→top order) for the group.
392    pub stack: Stack,
393    /// Indices into the caller's worktree slice for members of this stack, ordered by
394    /// the member branch's position in `stack.diffs` (bottom→top). Members whose
395    /// branch isn't found in `stack.diffs` sort last, in original input order.
396    pub members: Vec<usize>,
397}
398
399/// Result of partitioning a worktree slice by stacks via [`group_by_stack`].
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct StackGrouping {
402    /// Groups of worktrees sharing a connected stack, ordered by `(trunk, sorted branch set)`.
403    pub groups: Vec<StackGroup>,
404    /// Indices of worktrees with no stack (trunk branches, untracked branches), in input order.
405    pub ungrouped: Vec<usize>,
406}
407
408/// Partition a slice of optional stack descriptors into groups sharing the same connected stack.
409///
410/// The grouping key is `(trunk, sorted diff set)`, so worktrees in the same connected stack
411/// always collapse into one group regardless of the [`Stack::diffs`] vector order returned
412/// by [`current_stack`]. Members within each group are ordered by their branch's position in
413/// the representative stack's `diffs` list (bottom→top).
414///
415/// Groups are returned in deterministic order: sorted by `(trunk, sorted branch set)`.
416///
417/// When all entries are `None` (stack model inactive or `--no-stack`), `groups` is empty and
418/// every index appears in `ungrouped` — callers get identical flat-list behavior.
419pub fn group_by_stack(stacks: &[Option<Stack>]) -> StackGrouping {
420    // BTreeMap provides sorted iteration: (trunk, branch_set) order automatically.
421    let mut map: BTreeMap<(String, Vec<String>), (Stack, Vec<usize>)> = BTreeMap::new();
422    let mut ungrouped: Vec<usize> = Vec::new();
423
424    for (i, opt) in stacks.iter().enumerate() {
425        match opt {
426            None => ungrouped.push(i),
427            Some(stack) => {
428                let mut key_branches = stack.diffs.clone();
429                key_branches.sort();
430                let key = (stack.trunk.clone(), key_branches);
431                let entry = map
432                    .entry(key)
433                    .or_insert_with(|| (stack.clone(), Vec::new()));
434                entry.1.push(i);
435            }
436        }
437    }
438
439    let groups = map
440        .into_values()
441        .map(|(rep_stack, mut members)| {
442            // Sort members by their branch's position in rep_stack.diffs (bottom→top).
443            members.sort_by_key(|&idx| {
444                let branch = stacks[idx]
445                    .as_ref()
446                    .map(|s| s.current.as_str())
447                    .unwrap_or("");
448                rep_stack
449                    .diffs
450                    .iter()
451                    .position(|b| b == branch)
452                    .unwrap_or(usize::MAX)
453            });
454            StackGroup {
455                stack: rep_stack,
456                members,
457            }
458        })
459        .collect();
460
461    StackGrouping { groups, ungrouped }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use git_workon_fixture::prelude::*;
468
469    /// `detect` must resolve `Graphite` from repo metadata alone, with no dependency on whether
470    /// `gt` is installed on the host running the tests.
471    ///
472    /// The "gt absent" half cannot be forced from inside this process: it would mean mutating
473    /// `PATH`, which is process-global (`unsafe` under Rust 2024) and would race every other test
474    /// in this binary. Only the CLI suite can scrub `PATH` hermetically, by passing it to a child
475    /// process (`git-workon/tests/suite/new.rs`'s `path_without_gt_new`). So the guard here is
476    /// environmental rather than hermetic — CI has no `gt`, so a reintroduced `detect_gt()` gate
477    /// turns this red there. Its value is making that failure say "detection must not require gt"
478    /// instead of surfacing as a handful of unrelated review-TUI changeset-count assertions.
479    #[test]
480    fn detect_resolves_graphite_from_repo_metadata_without_requiring_the_gt_binary() {
481        let fixture = FixtureBuilder::new()
482            .graphite_config(&["main"])
483            .branch_metadata("a", "main")
484            .build()
485            .unwrap();
486        let repo = fixture.repo().unwrap();
487
488        assert_eq!(
489            StackModel::detect(repo),
490            StackModel::Graphite,
491            "a gt-initialized repo is a Graphite stack regardless of PATH"
492        );
493    }
494
495    #[test]
496    fn detect_resolves_gh_stack_from_canonical_file() {
497        let fixture = FixtureBuilder::new()
498            .gh_stack(None, 1, "main", &["feat-a"])
499            .build()
500            .unwrap();
501        let repo = fixture.repo().unwrap();
502
503        assert_eq!(StackModel::detect(repo), StackModel::GhStack);
504    }
505
506    #[test]
507    fn detect_resolves_mixed_when_both_tools_have_live_branches() {
508        let fixture = FixtureBuilder::new()
509            .graphite_config(&["main"])
510            .branch_metadata("a", "main")
511            .gh_stack(None, 1, "main", &["feat-a"])
512            .build()
513            .unwrap();
514        let repo = fixture.repo().unwrap();
515
516        assert_eq!(
517            StackModel::detect(repo),
518            StackModel::Mixed {
519                primary: StackProvider::GhStack
520            },
521            "both tools have a ref-backed tracked branch, so neither is hidden"
522        );
523    }
524
525    #[test]
526    fn detect_resolves_gh_stack_when_only_gh_stack_has_live_branches() {
527        // Graphite's only metadata row is a ghost (branch ref deleted) — Graphite's artifacts
528        // are stale, gh-stack's are not.
529        let fixture = FixtureBuilder::new()
530            .graphite_config(&["main"])
531            .ghost_branch_metadata("a", "main")
532            .gh_stack(None, 1, "main", &["feat-a"])
533            .build()
534            .unwrap();
535        let repo = fixture.repo().unwrap();
536
537        assert_eq!(
538            StackModel::detect(repo),
539            StackModel::GhStack,
540            "Graphite's only row is a ghost; gh-stack's tracked branch is live"
541        );
542    }
543
544    #[test]
545    fn detect_resolves_graphite_when_gh_stack_has_only_ghost_branches() {
546        let fixture = FixtureBuilder::new()
547            .graphite_config(&["main"])
548            .branch_metadata("a", "main")
549            .gh_stack(None, 1, "main", &[])
550            .gh_stack_ghost_branch(None, 1, "feat-a")
551            .build()
552            .unwrap();
553        let repo = fixture.repo().unwrap();
554
555        assert_eq!(
556            StackModel::detect(repo),
557            StackModel::Graphite,
558            "gh-stack's only row is a ghost; Graphite's tracked branch is live"
559        );
560    }
561
562    #[test]
563    fn detect_resolves_graphite_when_gh_stack_has_only_merged_branches() {
564        // `gh stack sync` keeps a merged branch's row, and the ref survives while a worktree
565        // has it checked out, so the row is ref-backed but not live.
566        let fixture = FixtureBuilder::new()
567            .graphite_config(&["main"])
568            .branch_metadata("a", "main")
569            .gh_stack(None, 1, "main", &["feat-a"])
570            .gh_stack_merged_branch(None, 1, "feat-a")
571            .build()
572            .unwrap();
573        let repo = fixture.repo().unwrap();
574
575        assert_eq!(
576            StackModel::detect(repo),
577            StackModel::Graphite,
578            "gh-stack's only row is merged; Graphite's tracked branch is live"
579        );
580    }
581
582    #[test]
583    fn detect_resolves_graphite_when_both_tools_have_only_ghost_branches() {
584        let fixture = FixtureBuilder::new()
585            .graphite_config(&["main"])
586            .ghost_branch_metadata("a", "main")
587            .gh_stack(None, 1, "main", &[])
588            .gh_stack_ghost_branch(None, 1, "feat-a")
589            .build()
590            .unwrap();
591        let repo = fixture.repo().unwrap();
592
593        assert_eq!(
594            StackModel::detect(repo),
595            StackModel::GhStack,
596            "neither tool has a live branch; gh-stack is the default, doctor explains the ambiguity"
597        );
598    }
599
600    fn stack(trunk: &str, diffs: &[&str], current: &str) -> Stack {
601        Stack {
602            trunk: trunk.to_string(),
603            diffs: diffs.iter().map(|s| s.to_string()).collect(),
604            current: current.to_string(),
605            parents: HashMap::new(),
606            number: None,
607            merged: HashSet::new(),
608        }
609    }
610
611    #[test]
612    fn empty_input_yields_empty_grouping() {
613        let result = group_by_stack(&[]);
614        assert!(result.groups.is_empty());
615        assert!(result.ungrouped.is_empty());
616    }
617
618    #[test]
619    fn all_none_yields_all_ungrouped() {
620        let stacks: Vec<Option<Stack>> = vec![None, None, None];
621        let result = group_by_stack(&stacks);
622        assert!(result.groups.is_empty());
623        assert_eq!(result.ungrouped, vec![0, 1, 2]);
624    }
625
626    #[test]
627    fn single_stacked_worktree_forms_one_group() {
628        let stacks = vec![Some(stack("main", &["feat-a"], "feat-a"))];
629        let result = group_by_stack(&stacks);
630        assert!(result.ungrouped.is_empty());
631        assert_eq!(result.groups.len(), 1);
632        assert_eq!(result.groups[0].members, vec![0]);
633        assert_eq!(result.groups[0].stack.trunk, "main");
634    }
635
636    #[test]
637    fn two_worktrees_same_stack_collapse_into_one_group_ordered_bottom_to_top() {
638        // Two worktrees in the same 2-branch stack: feat-b is bottom, feat-a is top.
639        // Input idx 0 has current="feat-a" (position 1), input idx 1 has current="feat-b" (position 0).
640        let stacks = vec![
641            Some(stack("main", &["feat-b", "feat-a"], "feat-a")), // top worktree
642            Some(stack("main", &["feat-b", "feat-a"], "feat-b")), // bottom worktree
643        ];
644        let result = group_by_stack(&stacks);
645        assert!(result.ungrouped.is_empty());
646        assert_eq!(result.groups.len(), 1);
647        // Members ordered by branch position: idx 1 (feat-b, pos 0) first, idx 0 (feat-a, pos 1) second.
648        assert_eq!(result.groups[0].members, vec![1, 0]);
649    }
650
651    #[test]
652    fn same_stack_different_branches_vector_order_still_collapses() {
653        // The two Stacks have the same set but different vector orderings — must collapse.
654        let stacks = vec![
655            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
656            Some(stack("main", &["feat-b", "feat-a"], "feat-b")),
657        ];
658        let result = group_by_stack(&stacks);
659        assert_eq!(
660            result.groups.len(),
661            1,
662            "different branch vector order must still collapse"
663        );
664        assert!(result.ungrouped.is_empty());
665    }
666
667    #[test]
668    fn two_distinct_stacks_on_same_trunk_stay_separate() {
669        let stacks = vec![
670            Some(stack("main", &["stack1-a"], "stack1-a")),
671            Some(stack("main", &["stack2-a"], "stack2-a")),
672        ];
673        let result = group_by_stack(&stacks);
674        assert_eq!(result.groups.len(), 2);
675        assert!(result.ungrouped.is_empty());
676    }
677
678    #[test]
679    fn mixed_stacked_and_ungrouped() {
680        let stacks = vec![
681            None, // trunk worktree
682            Some(stack("main", &["feat-a", "feat-b"], "feat-a")),
683            None, // another ungrouped
684            Some(stack("main", &["feat-a", "feat-b"], "feat-b")),
685        ];
686        let result = group_by_stack(&stacks);
687        assert_eq!(result.ungrouped, vec![0, 2]);
688        assert_eq!(result.groups.len(), 1);
689        // feat-a is position 0, feat-b is position 1 → member idx 1 (current=feat-a) first, then idx 3
690        assert_eq!(result.groups[0].members, vec![1, 3]);
691    }
692
693    #[test]
694    fn groups_ordered_deterministically_by_trunk_then_branch_set() {
695        let stacks = vec![
696            Some(stack("main", &["z-feat"], "z-feat")),
697            Some(stack("dev", &["d-feat"], "d-feat")),
698            Some(stack("main", &["a-feat"], "a-feat")),
699        ];
700        let result = group_by_stack(&stacks);
701        assert_eq!(result.groups.len(), 3);
702        // Ordered: (dev, [d-feat]), (main, [a-feat]), (main, [z-feat])
703        assert_eq!(result.groups[0].stack.trunk, "dev");
704        assert_eq!(result.groups[1].stack.diffs, vec!["a-feat"]);
705        assert_eq!(result.groups[2].stack.diffs, vec!["z-feat"]);
706    }
707}