Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ git workon doctor --dry-run # preview fixes without applying
Checks performed:
- **Worktrees**: missing directories, broken git links, gone upstreams
- **Dependencies**: `gh` CLI (PR features), `gh` auth status, git remote, `gt` CLI (stack features), hook commands in PATH
- **Configuration**: renamed config keys (auto-fixable), invalid `stackModel`/`stackWorktreeGranularity`, invalid `prFormat`, `defaultBranch` not found in repo, `stackModel=graphite` without `gt init`
- **Configuration**: renamed config keys (auto-fixable), invalid `stackModel`/`stackWorktreeGranularity`, invalid `prFormat`, `defaultBranch` not found in repo, `stackModel=graphite` without `gt init`, both Graphite and gh-stack artifacts present (reports what `auto` resolved to and each provider's liveness), an explicit `stackModel` pin hiding the other provider's tracked branches

### Copy untracked files between worktrees

Expand Down Expand Up @@ -256,7 +256,8 @@ man git-workon
pruneBranches = true # also consider local branches with no worktree

# Stacked diffs (Graphite or gh-stack)
stackModel = auto # "auto", "graphite", "gh-stack", "git", or "none"
stackModel = auto # "auto", "graphite", "gh-stack", "git", "none",
# "mixed:graphite", or "mixed:gh-stack"
stackWorktreeGranularity = stack # "stack" (one worktree per stack)
stackAutoTrack = true # auto-register new branches with the active stack tool after 'workon new'
gtAutoTrack = true # deprecated alias for stackAutoTrack, read only as a fallback
Expand Down
48 changes: 36 additions & 12 deletions docs/adr/028-provider-neutral-stack-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,42 @@ pre-existing per-worktree files via `migrate_worktree`; the union read in
`gh_stack::read_metadata` survives as a degraded fallback for whatever isn't yet linked. See
"The shared canonical file" below for why the symlink approach works at all.

## Detection: Graphite wins

`StackModel::detect` (`stack.rs`) checks Graphite first, then gh-stack: `.graphite_repo_config`
or `.graphite_metadata.db` existing means `Graphite`; otherwise a `gh-stack` file anywhere
`gh_stack::is_gh_stack_repo` looks means `GhStack`; otherwise `None`. `.graphite_repo_config`
comes from an explicit, repo-wide `gt init`, while a `gh-stack` file can appear as a side effect
of one `gh stack add` run in a single worktree, so the more deliberate, repo-scoped signal wins.
No repository that resolves to `Graphite` today can silently flip to `GhStack` because someone
tried the other tool once in one worktree. The escape hatch is an explicit
`workon.stackModel = gh-stack`; `doctor`'s `BothStackToolsDetected` check surfaces the ambiguity
when both artifacts are present so the user knows to pin the config if `auto` picked the wrong
one.
## Detection: live-metadata tie-break

> **Amended 2026-09-10, 2026-09-16.** This section replaces the original "Graphite wins" rule
> (both artifacts present always resolved to `Graphite`) with the live-metadata tie-break below.
> The old rule is in this file's git history.

`StackModel::detect` (`stack.rs`) checks artifacts first: `.graphite_repo_config` or
`.graphite_metadata.db` existing means Graphite artifacts are present; a `gh-stack` file
anywhere `gh_stack::is_gh_stack_repo` looks means gh-stack artifacts are present. With only one
artifact present, detection returns that provider directly (`None` if neither) — unchanged from
before, and no metadata read is needed since there is no ambiguity.

With **both** artifacts present, detection reads each provider's liveness — whether it has at
least one ref-backed tracked branch (`graphite::has_live_branches` /
`gh_stack::has_live_branches`, checking `StackMetadata::parents`' keys against
`resolve::branch_exists`, never probing the `gt` binary) — and ties break on the table:

| Graphite live | gh-stack live | Resolves to |
| --- | --- | --- |
| yes | yes | `StackModel::Mixed { primary: GhStack }` |
| yes | no | `StackModel::Graphite` (gh-stack's artifact is stale) |
| no | yes | `StackModel::GhStack` (Graphite's artifact is stale) |
| no | no | `StackModel::GhStack` (doubly stale; `doctor` explains) |

`Mixed` makes gh-stack primary — GitHub's native stack support removed the reason to treat
Graphite's repo-wide `gt init` as the more deliberate signal, so gh-stack answers first now — but
unlike the old "Graphite wins" rule, Graphite's tracked branches are never hidden: `current_stack`
and `enumerate_stacks` fall back to the secondary provider per branch (see
`StackModel::providers`), and `assemble_changesets`'s `Mixed` arm reads whichever provider's
metadata actually tracks the head branch. The escape hatch out of `Mixed`, or out of a tie-break
you disagree with, is an explicit `workon.stackModel = graphite`/`gh-stack` (strict, no fallback)
or `workon.stackModel = mixed:graphite`/`mixed:gh-stack` (pins `Mixed`'s primary directly,
skipping the liveness read); none of these are ever reachable via `auto`. `doctor`'s
`BothStackToolsDetected` check reports the dual state, what `auto` resolved to, and each
provider's liveness; a `StackModelPinHidesLiveBranches` check (and a matching `list` stderr hint)
fires when an explicit pin hides the other provider's live branches.

## The shared canonical file

Expand Down
43 changes: 35 additions & 8 deletions docs/recipes/stacked-diffs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ git-workon integrates with [Graphite](https://graphite.dev) and with
workflows. When either tool is active, `list`, `find`, and `new` all become stack-aware by
default. Use `--no-stack` on any invocation to fall back to branch-flat behavior.

If both tools' artifacts are present in the same repository, Graphite wins: see
"Auto-detection and both tools present" below.
If both tools' artifacts are present in the same repository, `auto` ties on which one has
tracked branches — using both at once is supported: see "Auto-detection and both tools present"
below.

## Setup

Expand Down Expand Up @@ -46,17 +47,43 @@ git config workon.stackModel none

### Auto-detection and both tools present

`workon.stackModel = auto` (the default) checks Graphite's artifacts first, then gh-stack's:
`.graphite_repo_config` comes from an explicit, repo-wide `gt init`, while a `gh-stack` file can
appear from a single `gh stack add` run in one worktree, so the more deliberate signal wins. If
you've tried both tools in the same repository and want gh-stack instead, pin it explicitly:
`workon.stackModel = auto` (the default) checks artifacts first — `.graphite_repo_config`/
`.graphite_metadata.db` for Graphite, a `gh-stack` file anywhere for gh-stack. With only one
present, that tool is used, same as ever. With **both** present, `auto` checks which one has
live, ref-backed tracked branches:

- **Both have tracked branches** — you're using both tools in the same repository (e.g. one
stack under Graphite, another under `gh stack`). `auto` resolves to a mixed mode, gh-stack
first: gh-stack answers for any branch, Graphite answers for branches gh-stack doesn't know
about. `list` shows both stacks; nothing is hidden.
- **Only one has tracked branches** — the other tool's artifacts are stale (e.g. you ran
`gt init` once and never tracked a branch, or migrated off it). `auto` uses the one with
tracked branches.
- **Neither has tracked branches** — an ambiguous, doubly-stale state; `auto` falls back to
gh-stack and `git workon doctor` explains why.

If you'd rather pin one tool explicitly and stop `auto` from ever falling back to the other,
set it directly:

```bash
git config workon.stackModel gh-stack
```

`git workon doctor` reports `BothStackToolsDetected` when it sees artifacts for both, so you
know when `auto` made a call you might want to override.
An explicit pin is strict: it never falls back to the other provider, even if that provider also
has tracked branches. If it does, `list` prints a dimmed stderr hint and `git workon doctor`
reports `StackModelPinHidesLiveBranches`, so you know branches are being hidden on purpose (or
by accident). `git workon doctor` also reports `BothStackToolsDetected` whenever both artifacts
are present under `auto`, showing what it resolved to and each provider's liveness.

You can also pin the mixed mode itself, naming its primary directly and skipping the liveness
read entirely:

```bash
git config workon.stackModel mixed:graphite # or mixed:gh-stack
```

This is useful when you know you want the fallback behavior but disagree with which provider
`auto` would pick as primary.

## Worktree per stack

Expand Down
25 changes: 24 additions & 1 deletion git-workon-lib/src/changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
//! still walked through, so live descendants of a ghost still appear. Falls back to the
//! `Git` arm when `head_branch` is a trunk branch or has no metadata row at all (mirrors
//! the nvim prototype's factory behavior).
//! - [`StackModel::Mixed`] → reads both providers' metadata and assembles from whichever one's
//! `parents` contains `head_branch`, checked in [`StackModel::providers`] order (primary
//! first); falls to the primary's metadata when neither tracks it, which then falls to `Git`
//! through the same trunk/untracked check as the single-provider arms.
//! - [`StackModel::Git`] → no metadata; walks `upstream..head_branch` commit-by-commit
//! (oldest first), one [`Changeset`] per commit.
//!
Expand All @@ -30,7 +34,7 @@ use git2::{BranchType, Oid, Repository, StatusOptions};

use crate::error::{ChangesetError, Result};
use crate::stack::metadata::{self, StackMetadata};
use crate::stack::{gh_stack, graphite, StackModel};
use crate::stack::{gh_stack, graphite, StackModel, StackProvider};

/// What a [`Changeset`] spans: a resolved commit range, or the working tree + index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -82,6 +86,25 @@ pub fn assemble_changesets(
StackModel::GhStack => {
assemble_from_metadata(repo, head_branch, &gh_stack::read_metadata(repo)?)
}
StackModel::Mixed { primary } => {
// Read both providers' metadata and assemble from whichever one actually tracks
// `head_branch` — checked in provider order, primary first. Neither tracking it
// falls to the primary's metadata, which `assemble_from_metadata` itself routes to
// `assemble_git` (head_branch is a trunk or has no metadata row either way).
let (graphite_meta, gh_stack_meta) = (
graphite::read_metadata(repo)?,
gh_stack::read_metadata(repo)?,
);
let meta_for = |provider: StackProvider| match provider {
StackProvider::Graphite => &graphite_meta,
StackProvider::GhStack => &gh_stack_meta,
};
let owner = model
.providers()
.find(|&p| meta_for(p).parents.contains_key(head_branch))
.unwrap_or(primary);
assemble_from_metadata(repo, head_branch, meta_for(owner))
}
}
}

Expand Down
29 changes: 20 additions & 9 deletions git-workon-lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
//! - **workon.pruneProtectedBranches** - Branches protected from pruning (multi-value, default: [])
//! - **workon.pruneGone** - Treat gone-upstream worktrees as prune candidates by default (bool, default: false)
//! - **workon.pruneFetch** - Fetch from tracked remotes before evaluating gone status (bool, default: false)
//! - **workon.stackModel** - Active stack model: "auto", "graphite", "git", or "none" (string, default: "auto")
//! - **workon.stackModel** - Active stack model: "auto", "graphite", "gh-stack", "git", "none",
//! "mixed:graphite", or "mixed:gh-stack" (string, default: "auto")
//! - **workon.stackWorktreeGranularity** - Worktree granularity for stacked diffs: "stack" (string, default: "stack")
//! - **workon.stackAutoTrack** - Auto-register new branches with the active stack tool after
//! `workon new` (bool, default: true)
Expand Down Expand Up @@ -62,7 +63,7 @@ use std::time::Duration;
use git2::Repository;

use crate::error::{ConfigError, Result, StackError};
use crate::stack::{Granularity, StackModel};
use crate::stack::{Granularity, StackModel, StackProvider};

/// Configuration reader for workon settings stored in git config.
///
Expand Down Expand Up @@ -318,15 +319,19 @@ impl<'repo> WorkonConfig<'repo> {
///
/// Auto-detection: returns `Graphite` when the repo has been `gt init`-ed
/// (`.graphite_repo_config` or `.graphite_metadata.db` exists), else `GhStack` when a
/// gh-stack file is present, else `None`. Graphite wins when both are present — see
/// [`StackModel::detect`].
/// gh-stack file is present, else `None`. When both tools' artifacts are present, ties
/// break on which has ref-backed tracked branches: both live → `Mixed` (gh-stack primary,
/// Graphite fallback); only one live → that provider, strict; neither live → `GhStack` —
/// see [`StackModel::detect`] for the full table.
///
/// Accepted config values: `"graphite"`, `"gh-stack"`, `"git"`, `"none"`, `"auto"`
/// (re-runs detection). `"git"` opts into metadata-less git-inference
/// ([`StackModel::Git`]) explicitly — it is never the result of `"auto"`. `"ghstack"`
/// (no hyphen) is a *different* tool (Meta's Phabricator-style stacker) and is rejected
/// as unsupported rather than treated as a typo for `"gh-stack"`. Anything else returns
/// an error.
/// (re-runs detection), `"mixed:graphite"`, `"mixed:gh-stack"` (explicit `Mixed` pin,
/// naming the primary directly, skipping the liveness read). `"git"` opts into
/// metadata-less git-inference ([`StackModel::Git`]) explicitly — it is never the result
/// of `"auto"`. `"ghstack"` (no hyphen) is a *different* tool (Meta's Phabricator-style
/// stacker) and is rejected as unsupported rather than treated as a typo for `"gh-stack"`.
/// Bare `"mixed"` and any other `"mixed:<x>"` fall through to `UnknownModel`, same as any
/// other unrecognized value.
pub fn stack_model(&self, cli_override: Option<&str>) -> Result<StackModel> {
let raw = if let Some(val) = cli_override {
Some(val.to_string())
Expand All @@ -341,6 +346,12 @@ impl<'repo> WorkonConfig<'repo> {
Some("graphite") => Ok(StackModel::Graphite),
Some("gh-stack") => Ok(StackModel::GhStack),
Some("git") => Ok(StackModel::Git),
Some("mixed:graphite") => Ok(StackModel::Mixed {
primary: StackProvider::Graphite,
}),
Some("mixed:gh-stack") => Ok(StackModel::Mixed {
primary: StackProvider::GhStack,
}),
Some(other) if matches!(other, "branchless" | "sapling" | "spr" | "ghstack") => {
Err(StackError::UnsupportedModel {
model: other.to_string(),
Expand Down
5 changes: 3 additions & 2 deletions git-workon-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ pub use crate::resolve::*;
pub use crate::stack::{
current_stack, enumerate_stacks, gh_stack_divergent_stack_numbers, gh_stack_readability_errors,
gh_stack_worktree_link_status, graphite_trunk, group_by_stack, is_gh_stack_repo,
is_graphite_active, is_graphite_repo, link_worktree, migrate_worktree, register_branch,
GhStackLinkStatus, Granularity, Stack, StackGroup, StackGrouping, StackModel,
is_graphite_active, is_graphite_repo, link_worktree, migrate_worktree,
provider_has_live_branches, register_branch, GhStackLinkStatus, Granularity, Stack, StackGroup,
StackGrouping, StackModel, StackProvider,
};
pub use crate::stash::*;
pub use crate::workon_root::*;
Expand Down
Loading