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}