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}