Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ git-stack is a CLI (`git stack <cmd>`) and stdio MCP server for GitHub's native
- Read and write git config via `git config`, never by editing files as text.
- Never edit `~/.claude.json` or `~/.codex/config.toml` directly; use `claude mcp` / `codex mcp`.
- `install` must never clobber existing user aliases or config without confirmation, and `--uninstall` removes only what we recorded as managed.
- Only `pkg/backend/ghstack` writes gh-stack's `<git-dir>/gh-stack` file, through its `Update` (or, before running `gh stack` on a stack kept in another worktree's file, `adopt`, which moves that record into the current worktree's) under `gh-stack.lock`, editing the JSON in place; nothing else writes it, and `gh stack` commands do every other mutation.
- Only `pkg/backend/ghstack` writes gh-stack's shared `<common-dir>/gh-stack` file, through its `Update` (which also moves in any per-worktree file left from gh-stack 0.2, see `Migrate`) under `gh-stack.lock`, editing the JSON in place; nothing else writes it, and `gh stack` commands do every other mutation.

## Testing
- Tests must never touch the real user environment. Use `gittest.Isolate(t)` (sets `HOME`, `XDG_CONFIG_HOME`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_NOSYSTEM`) in any test that runs git or installers.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ You'll need:

- git 2.40+
- `gh` (the [GitHub CLI](https://cli.github.com/)) logged in via `gh auth login`, plus the stack
extension; `gh extension install github/gh-stack`
extension, v0.3.0 or later; `gh extension install github/gh-stack` (or `gh extension upgrade
stack`)

Then grab the archive for your system from the
[releases page](https://github.com/DomBlack/git-stack/releases). Builds are for Linux and macOS
Expand Down Expand Up @@ -628,7 +629,7 @@ come back. If a branch was deleted by mistake, the output says which commit it w
Nothing gets pushed; that's what `git ss` is for. If your local trunk has diverged from the remote,
it's only reset with `-f` or a yes at the prompt.

Stacks are per worktree (that's how gh stack stores them), but sync covers all of them. A branch
Every worktree shares one set of stacks, and sync covers all of them. A branch
checked out in another worktree is fast forwarded there too, and like `git pull` any uncommitted
changes it doesn't touch come along. If a change or an untracked file is in the way, the branch
is left where it was and sync ends on `⚠ Synced, but main was not updated`, naming the files
Expand Down
37 changes: 22 additions & 15 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ All of this came from reading the gh-stack source (pinned at commit `2bd699a`, a
its `schema.json` sits in `pkg/backend/ghstack/testdata`). Several of these findings
overturned assumptions in the original brief, so they're worth keeping.

**Storage.** One JSON file at `<git-dir>/gh-stack` (`schemaVersion: 1`), plus
`gh-stack.lock`, `gh-stack-rebase-state` and `gh-stack-modify-state` next to it. No refs,
no git config keys. The shape is roughly;
**Storage.** One JSON file at `<common-dir>/gh-stack` (`schemaVersion: 1`), shared by every
worktree of the repository, plus `gh-stack.lock` (held while the file is written),
`gh-stack-operation.lock` (held for the whole of a gh stack command that changes stacks),
`gh-stack-rebase-state` and `gh-stack-modify-state` next to it. No refs, no git config keys.
The shape is roughly;

```json
{ "schemaVersion": 1, "repository": "github.com:owner/name",
Expand All @@ -116,12 +118,21 @@ no git config keys. The shape is roughly;
- The parent relationship is array order (bottom to top). There is no parent field, so
stacks are strictly linear.
- Trunk is per stack and several stacks can share one. A branch is in at most one stack.
- `<git-dir>` is per worktree, so a linked worktree doesn't see the main checkout's stacks.
git-stack reads every worktree's file, but `gh stack submit` and `gh stack add` only find
a stack in the file of the worktree they run in. So before either, the backend moves the
current branch's stack record, untouched, into this worktree's file when it lives in another
(a stack started in the main checkout and then checked out in a worktree, say), and moves
it back the same way when the branch is next submitted from somewhere else.
- Up to gh-stack 0.2 the file lived in each worktree's own `<git-dir>`, and gh stack only read
the one in the worktree it ran in. 0.3 (gh-stack PR #528, not in its release notes) moved
to the one shared file. The first 0.3 command moves each worktree's file into it, keeping
`<file>.pre-worktree-migration` backups and a `gh-stack-migration` journal while it works,
and from then on refuses for good if a backup it made differs from the file it would
move. Older git-stack versions kept writing per-worktree files after gh stack had moved them,
which is exactly how that happens. So git-stack needs gh-stack 0.3 or later (preflight
checks), reads and writes only the shared file, and moves any per-worktree file in itself
before its next write or `gh stack` call (`Migrate`): the same merge as gh stack's, under
both of gh stack's locks, but a backup name that's taken goes to `.pre-worktree-migration.1`
(and on) instead of stopping. Where two files hold a stack with the same branch the shared
file wins, then the current worktree's; the losing record survives in its backup. While gh
stack's own journal exists, or a worktree whose file would move has an interrupted gh stack
rebase or modify, git-stack leaves the files alone and says why. `Load` reads per-worktree
files too, without writing, so stacks look the same before and after the move.
- `gh stack view --json` exists but only covers the current stack, calls the GitHub API,
and writes the stack file as a side effect. Useless for completion or for listing every
stack.
Expand Down Expand Up @@ -314,9 +325,7 @@ holds a change (by patch id, ignoring what came from trunk) the PR never saw; a
that sync restacked since its last push differs only by commit id and is deleted as usual.
Patch ids are `--verbatim` rather than `--stable`, so a whitespace only amend counts as a
change and keeps the branch.
A branch recorded in two worktrees' metadata files is read from the first; once it is
deleted the stale record in the other file shows up as gone on the next sync and is
dropped then, so it heals over two syncs. A candidate checked out in the current worktree whose
A candidate checked out in the current worktree whose
trunk is checked out in another worktree leaves HEAD detached at the trunk tip rather than
failing. Moving the current checkout off a branch being deleted is a plain `git switch`, so
uncommitted changes git can carry come along; only git's "would be overwritten" refusal keeps
Expand Down Expand Up @@ -419,9 +428,7 @@ stack. All three go through one reshape step in `pkg/app`. It snapshots the stac
to change, rewrites them in a single `Update`, then hands the branches whose parent changed to
the native restack, so nothing is checked out (init stops after the `Update` and just says which
branches need a restack). `Update` grew for this: its callback may now append stacks (written to
the current worktree's own file, which is created with `schemaVersion: 1` if there isn't one
yet; gh stack only reads the file in the git dir it runs in, so a stack put in the main
checkout's file from a linked worktree was invisible to `gh stack submit` there) and clear or set a stack's id and number, though it
the shared file, which is created with `schemaVersion: 1` if there isn't one yet) and clear or set a stack's id and number, though it
still can't drop or reorder stacks. In that same `Update` every branch whose parent changed gets
its recorded base fixed to where its own commits start (the recorded base if it's still in the
branch's history, else the merge base with its old parent), because the restack replays
Expand Down
4 changes: 3 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ Tool errors (`isError: true`) carry a JSON object in the text content:
Codes: `not_repo`, `not_in_stack`, `not_at_top`, `conflict`, `partial`, `rebase_active`, `locked`,
`stacks_unavailable`, `auth_required`, `not_installed`, `unsupported`, `invalid_args`,
`interaction_required`, `api_failure`, `disambiguate`, `modify_recovery`, `checks_failing`,
`checks_pending`, `signing_failed`, `unknown`.
`checks_pending`, `signing_failed`, `state_migration`, `unknown`.
`state_migration` means gh stack's stack file is in the layout gh-stack 0.2 used (one file per
worktree) and can't be moved into the shared file yet; `next_steps` says what is in the way.
`signing_failed` comes from `stack_create` and `stack_modify` when git could not sign the commit,
and from `stack_restack`, `stack_modify` and `stack_continue` when the git rebase a conflict
needs could not sign a replayed commit; most often an SSH signing key whose passphrase nobody
Expand Down
8 changes: 3 additions & 5 deletions pkg/app/reshape.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ type reshapeSnapshot struct {
}

type snapshotStack struct {
Trunk string `json:"trunk"`
// Worktree is the working tree whose metadata held the stack.
Worktree string `json:"worktree,omitempty"`
Trunk string `json:"trunk"`
ID string `json:"id,omitempty"`
Number int `json:"number,omitempty"`
Branches []snapshotBranch `json:"branches"`
Expand All @@ -55,7 +53,7 @@ func snapshotOf(g *stack.Graph, names []string) *reshapeSnapshot {
continue
}
seen[i] = true
ss := snapshotStack{Trunk: s.Trunk, Worktree: s.Worktree, ID: s.ID, Number: s.Number}
ss := snapshotStack{Trunk: s.Trunk, ID: s.ID, Number: s.Number}
for _, b := range s.Branches {
ss.Branches = append(ss.Branches, snapshotBranch{Name: b.Name, Base: b.Base})
}
Expand Down Expand Up @@ -181,7 +179,7 @@ func (a *App) restoreReshape(ctx context.Context, repo git.Repo, snap *reshapeSn
continue // every branch of it is gone; nothing to put back
}
s := &g.Stacks[homes[k]]
s.Trunk, s.Worktree, s.ID, s.Number = ss.Trunk, ss.Worktree, ss.ID, ss.Number
s.Trunk, s.ID, s.Number = ss.Trunk, ss.ID, ss.Number
if ss.ID != "" {
ids = append(ids, ss.ID)
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/app/reshape_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func trunksAndNames(g *stack.Graph) []string {

func TestRestoreReshapePutsAStackBackOnItsTrunk(t *testing.T) {
g := stack.NewGraph([]stack.Stack{
{Trunk: "main", Worktree: "/w", ID: "S1", Number: 1, Branches: []stack.Branch{{Name: "a", Base: "m"}, {Name: "b", Base: "ta"}}},
{Trunk: "main", ID: "S1", Number: 1, Branches: []stack.Branch{{Name: "a", Base: "m"}, {Name: "b", Base: "ta"}}},
})
snap := snapshotOf(g, []string{"a"})
snap.Created = []string{"a"}
Expand All @@ -159,7 +159,7 @@ func TestRestoreReshapePutsAStackBackOnItsTrunk(t *testing.T) {
if want := []string{"main:a,b"}; !slices.Equal(trunksAndNames(got), want) {
t.Fatalf("stacks = %v, want %v", trunksAndNames(got), want)
}
if s := got.Stacks[0]; s.Worktree != "/w" || s.ID != "S1" || s.Branches[0].Base != "m" || s.Branches[0].Head != "na" {
if s := got.Stacks[0]; s.ID != "S1" || s.Branches[0].Base != "m" || s.Branches[0].Head != "na" {
t.Errorf("stack = %+v", s)
}
}
Expand Down
13 changes: 11 additions & 2 deletions pkg/app/restack_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ func clearRestackState(repo git.Repo) error {
// rebase recorded for this worktree, which a user who upgraded mid
// conflict may still have.
func ghRebaseStateExists(repo git.Repo) bool {
_, err := os.Stat(filepath.Join(repo.GitDir, "gh-stack-rebase-state"))
return err == nil
// gh stack 0.3 keeps it with its catalog in the common dir; older
// versions kept it in the worktree's own git dir.
for _, dir := range []string{repo.CommonDir, repo.GitDir} {
if dir == "" {
continue
}
if _, err := os.Stat(filepath.Join(dir, "gh-stack-rebase-state")); err == nil {
return true
}
}
return false
}
16 changes: 8 additions & 8 deletions pkg/app/sync_cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ func TestSyncKeepsEmptyBranchAtTrunkTip(t *testing.T) {
f := newSyncFixture(t)
gittest.Run(t, f.dir, "branch", "empty", "main")
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "empty"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}},
{Trunk: "main", Branches: []stack.Branch{{Name: "empty"}}},
})}
res, err := f.sync(t, app.SyncOptions{NoRestack: true})
if err != nil || len(res.Deleted) != 0 || !branchExists(t, f.dir, "empty") {
Expand All @@ -243,7 +243,7 @@ func TestSyncDeletesBranchFastForwardedIntoTrunk(t *testing.T) {
gittest.Run(t, f.dir, "push", "-q", "origin", "main")
gittest.Run(t, f.dir, "switch", "-q", "b")
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a", Base: base}, {Name: "b", Base: f.rev(t, "a")}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a", Base: base}, {Name: "b", Base: f.rev(t, "a")}}},
})}
res, err := f.sync(t, app.SyncOptions{NoRestack: true})
if err != nil {
Expand All @@ -262,8 +262,8 @@ func TestSyncKeepsEmptyBranchWhoseBaseIsItsTip(t *testing.T) {
tip := f.rev(t, "main")
gittest.Run(t, f.dir, "branch", "empty", "main")
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "empty", Base: tip}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}},
{Trunk: "main", Branches: []stack.Branch{{Name: "empty", Base: tip}}},
})}
res, err := f.sync(t, app.SyncOptions{NoRestack: true})
if err != nil || len(res.Deleted) != 0 || !branchExists(t, f.dir, "empty") {
Expand All @@ -280,7 +280,7 @@ func TestSyncRestackKeepsOlderBaseOfBranchOnItsParent(t *testing.T) {
gittest.Run(t, f.dir, "switch", "-q", "b")
f.deps.Config.SyncPrune = config.SyncPruneNever // cleanup declines, so restack sees the branch
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a", Base: base}, {Name: "b", Base: f.rev(t, "a")}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a", Base: base}, {Name: "b", Base: f.rev(t, "a")}}},
})}
if _, err := f.sync(t, app.SyncOptions{}); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -506,8 +506,8 @@ func TestSyncDropsGoneBranchesFromMetadata(t *testing.T) {
f := newSyncFixture(t)
f.deps.Config.SyncPrune = config.SyncPruneNever
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "gone", PR: &stack.PRRef{Number: 3, Merged: true}}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}},
{Trunk: "main", Branches: []stack.Branch{{Name: "gone", PR: &stack.PRRef{Number: 3, Merged: true}}}},
})}
if _, err := f.sync(t, app.SyncOptions{NoRestack: true}); err != nil {
t.Fatal(err)
Expand Down
8 changes: 4 additions & 4 deletions pkg/app/sync_restack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ func TestSyncConflictStopsOneStackOnly(t *testing.T) {
gittest.Commit(t, f.dir, "README.md", "c version", "c clashes")
gittest.Run(t, f.dir, "switch", "-q", "main")
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "c"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}},
{Trunk: "main", Branches: []stack.Branch{{Name: "c"}}},
})}
cBefore := f.rev(t, "c")
f.advanceRemote(t, "main", "README.md") // conflicts with c
Expand Down Expand Up @@ -207,8 +207,8 @@ func TestSyncPlanningErrorFailsOneStack(t *testing.T) {
gittest.Commit(t, f.dir, "c.txt", "c", "feat: c")
gittest.Run(t, f.dir, "switch", "-q", "main")
f.deps.Meta = memMeta{stack.NewGraph([]stack.Stack{
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: f.repo.TopLevel},
{Trunk: "nope", Branches: []stack.Branch{{Name: "c"}}, Worktree: f.repo.TopLevel},
{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}},
{Trunk: "nope", Branches: []stack.Branch{{Name: "c"}}},
})}
f.advanceRemote(t, "main", "r.txt")
res, err := f.sync(t, app.SyncOptions{})
Expand Down
2 changes: 1 addition & 1 deletion pkg/app/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func newSyncFixture(t *testing.T) *syncFixture {
if err != nil {
t.Fatal(err)
}
graph := stack.NewGraph([]stack.Stack{{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}, Worktree: repo.TopLevel}})
graph := stack.NewGraph([]stack.Stack{{Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}}})
fg := &recordingForge{prs: []forge.PullRequest{{Number: 7, Head: "a", Base: "main", State: forge.StateOpen, URL: "u/a"}}}
cfg := config.Defaults()
cfg.CacheTTL = 0
Expand Down
Loading
Loading