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
12 changes: 10 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A Go CLI extension (`gh stack`) for managing stacked branches and pull requests. Uses Cobra for commands, bubbletea/lipgloss for TUI, and `stretchr/testify` for tests.

Repository operations require Git 2.36+.

## Build and validate

```sh
Expand All @@ -16,10 +18,11 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
## Project layout

- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
- `internal/git/`: `Ops` interface (52 methods) wrapping git CLI. `MockOps` for tests. Package-level functions delegate to swappable `ops` variable.
- `internal/git/`: `Ops` interface wrapping git CLI. `MockOps` for tests. Use `ForWorktree(path)` for scoped execution, `CommonDir()` for shared storage, and `GitDir()` for native per-worktree state. Never use production `os.Chdir` or `SetOps` to switch context.
- `internal/github/`: `ClientOps` interface (18 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`) with an explicit `merge_action` (`direct_merge` or `merge_queue`) chosen from the base branch's merge-queue detection. `merge_action` is optional — omitting it (or sending `default`) lets the server auto-route (merge queue if one is configured, else direct merge) — but the CLI sends it explicitly so a wrong detection fails loudly instead of silently merging directly.
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
- `internal/stack/`: Stack file (`.git/gh-stack`, JSON) management with file locking.
- `internal/stack/`: Shared catalog (`<common-dir>/gh-stack`, JSON), conservative legacy migration, atomic saves, and short catalog locks.
- `internal/worktree/`: Origin/owner identities, cleanliness preflight, scoped operations, and touched-ref recovery.
- `internal/tui/`: bubbletea views (`stackview`, `modifyview`).

## Coding conventions
Expand All @@ -32,5 +35,10 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai
- Mock GitHub: `cfg.GitHubClientOverride = &github.MockClient{...}`.
- Mock prompts: set `cfg.SelectFn`, `cfg.ConfirmFn`, or `cfg.InputFn`.
- Load stack files with `stack.Load(dir)` after writing to get correct checksums.
- Use `stackStateDir(cfg)` for application state and `beginStackMutation` before mutation snapshots; defer cleanup. The clone-wide operation lock is separate from short catalog saves.
- Recovery must match stack identity, execute in the recorded worktree, and retain journals on partial failures. Native Git markers stay per-worktree.
- Mutation locks coordinate gh-stack only, not Git commands/editors. Keep affected worktrees quiescent during rewrites. Context-tracked modify passes the snapshot SHA (or prior `Context.Touched` SHA) to `Context.Start`; never claim an external commit as this operation's work during continuation.
- Rebase/sync currently reject foreign-owned members and writable trunks after prerequisite migration but before requested mutations, including sync reconciliation. Their existing engine remains origin-only; rebase recovery must be invoked in its recorded origin.
- Core modify rejects distributed stack branches before TUI/apply; foreign trunk ownership alone is allowed. Do not enable distributed modify until its dependent layer is implemented.

For full architecture details, see [AGENTS.md](../AGENTS.md) in the repository root.
21 changes: 16 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A GitHub CLI (`gh`) extension for managing stacked branches and pull requests. Written in Go, it automates creating branches, keeping them rebased, setting PR base branches, and navigating between stack layers.

Repository operations require Git 2.36 or later.

## Build, test, and validate

```sh
Expand Down Expand Up @@ -32,17 +34,18 @@ cmd/ # Cobra commands (one file per command + tests)
utils.go # shared helpers, ExitError types, exit codes
internal/
git/ # git.Ops interface + defaultOps (exec-based)
gitops.go # Ops interface (52 methods)
gitops.go # Ops interface, including scoped worktree execution
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
github/ # github.ClientOps interface + real Client
client_interface.go # ClientOps interface (18 methods)
mock_client.go # MockClient. Uses function-pointer fields for testing.
stack/ # stack file (.git/gh-stack) management, JSON schema, locking
stack/ # common-directory catalog, JSON schema, migration, locking
schema.json # JSON Schema for the stack file format
config/ # Config struct (I/O, colors, test overrides)
testing.go # NewTestConfig(). Returns *Config + stdout/stderr pipes.
branch/ # branch naming (Slugify, DateSlug)
modify/ # interactive stack modification state machine
worktree/ # operation-owned worktree identities and ref recovery
pr/ # PR template discovery
tui/ # bubbletea/bubbles/lipgloss terminal UI
stackview/ # interactive stack visualization
Expand Down Expand Up @@ -108,18 +111,25 @@ if errors.As(err, &exitErr) { ... }

### Key interfaces

- **`git.Ops`** (`internal/git/gitops.go`): 52 methods wrapping git CLI calls. The production implementation uses `cli/go-gh`'s `client.Command()` via `run()` and `runSilent()` helpers. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable.
- **`git.Ops`** (`internal/git/gitops.go`): wraps git CLI calls. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable. `git.ForWorktree(path)` returns an explicitly scoped executor for HEAD/index/working-file operations. Never switch production context with `os.Chdir` or `git.SetOps`; reserve `SetOps` for tests. `GitDir()` remains per-worktree; `CommonDir()` is repository-wide.
- **Scoped Git errors:** `ForWorktree(path)` returns `(Ops, error)` and no executor for invalid contexts. Each scoped operation rechecks directory identity. `BranchExists`, `HasStagedChanges`, `IsRebaseInProgress`, and `IsCherryPickInProgress` return `(bool, error)`; callers must handle lookup failures before mutating Git or recovery state, not treat them as absence.
- **`github.ClientOps`** (`internal/github/client_interface.go`): 18 methods for GitHub API (PRs, stacks, merges). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL: allowed merge methods + viewer's default), `BaseBranchUsesMergeQueue` (GraphQL: detects a base-branch merge queue to select the explicit `merge_action`), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`). Injected via `cfg.GitHubClientOverride` in tests.
- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`).

### Stack file

- **Location:** `.git/gh-stack` (JSON format, schema version 1).
- **Location:** `<common-dir>/gh-stack` (JSON format, schema version 1), shared by all linked worktrees. Use `stackStateDir(cfg)` for app storage; it also selects original-worktree catalogs during legacy recovery.
- **Schema:** `internal/stack/schema.json`.
- **Identity:** each stack stores GitHub's global `id` (string) and repo-scoped `number` (int, shown in the GitHub UI and used as the primary way to reference a stack, e.g. `gh stack checkout <number>`). `number` may be `0` for stack files created before it was tracked; it is backfilled from the API on the next stack operation.
- **Locking:** Exclusive file lock at `.git/gh-stack.lock` with 5-second timeout. Errors surface as `LockError`.
- **Locking:** `<common-dir>/gh-stack.lock` protects short catalog saves; `<common-dir>/gh-stack-operation.lock` serializes clone-wide mutations. Acquire `beginStackMutation` before snapshots/preflight and defer its cleanup. Never hold a catalog lock across Git operations or call lock-taking `stack.Save` while already holding that lock. Errors surface as `LockError`.
- **Staleness:** Concurrent modifications detected via `StaleError`.
- **Migration:** Consolidate only nonconflicting legacy catalogs and preserve originals. Stop on conflicting definitions; finish legacy recovery in its original worktree before migrating. Do not mix old and new writers.
- **Recovery:** gh-stack journals live in the common directory and record the origin, stack identity, original refs, and progress. Native Git markers remain per-worktree. Rebase continue/abort currently requires the recorded original worktree; modify uses a scoped origin executor even when invoked elsewhere. Match stack identity (not catalog array position) and retain state on any partial restore or save failure.
- **External changes:** Mutation locks coordinate gh-stack, not arbitrary Git commands or editors. Keep affected worktrees quiescent during rewrites, except for requested conflict resolution while paused. Context-tracked modify calls `Context.Start(branch, expectedSHA)` before ref mutations, using the snapshot or last `Context.Touched` SHA. Do not adopt a freshly read tip as the operation's baseline during continuation.
- **Separate Git directories:** Native topology may report the administration directory as the main worktree path for `--separate-git-dir` repositories. A known origin remains usable, but a foreign main-owner root may be undiscoverable. Never infer a working directory from an administration path, emit it as a successful navigation target, or add a private registry/config mutation to guess ownership.
- **Core modify boundary:** Plain modify permits unoccupied branches and branches owned by its origin worktree, but rejects distributed stack membership before the TUI/apply. Trunk ownership alone does not block it. Full distributed modify is a separate layer.
- **Intermediate rebase/sync boundary:** Keep the existing origin-only execution engine. After prerequisite catalog migration, reject all foreign-owned member/rollback targets and any trunk that would be updated before requested mutations. Sync must check remote-added/replacement branches before importing or saving membership. Stack selection cannot eagerly checkout before this preflight. Multi-owner execution is deferred.
- **Journal transition:** New rebase/sync journals carry `executionMode: "origin-only"`. Their context identifies the origin, not distributed per-step progress. Future engines must recognize this marker before routing recovery and either use compatible origin-bound recovery or fail closed with matching-build instructions. Never reinterpret it as a distributed journal; this build likewise rejects unmarked non-null contexts and unknown modes. Legacy null-context journals retain their original-catalog route.

## CI workflows (`.github/workflows/`)

Expand All @@ -135,4 +145,5 @@ if errors.As(err, &exitErr) { ... }
- `git.SetOps()` replaces the **package-level** ops variable. Forgetting `defer restore()` in a test will break every subsequent test in the package.
- Interrupt detection: Ctrl+C is caught as `terminal.InterruptErr`, wrapped into an `errInterrupt` sentinel, and printed with a friendly message before a silent exit.
- Rerere: on first rebase conflict, the user is prompted to enable `git rerere`. If declined, a flag file prevents future prompts. `tryAutoResolveRebase()` loops up to 1000 times auto-continuing when rerere resolves conflicts.
- Date-preserving rebase starts use the merge backend so Git persists the date setting across conflicts. Continuations use native saved settings, not start-only date flags.
- The `.gitignore` ignores `/gh-stack` and `/gh-stack.exe` (the built binary).
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Stacked PRs break large changes into a chain of small, reviewable pull requests
gh extension install github/gh-stack
```

Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+.
Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+ and Git 2.36+.

## AI agent integration

Expand Down Expand Up @@ -60,7 +60,25 @@ When you submit, `gh stack` creates one PR per branch and links them together as

### Local tracking

Stack metadata is stored in `.git/gh-stack` (a JSON file, not committed to the repo). This tracks which branches belong to which stack and their ordering. Rebase state during interrupted rebases is stored separately in `.git/gh-stack-rebase-state`.
Stack metadata is stored in `<common-dir>/gh-stack` (a JSON file, not committed to the repo), where `<common-dir>` is Git's common directory. In a normal clone this is `.git/gh-stack`. All linked worktrees share this catalog, including stack membership, ordering, and PR metadata.

The gh-stack recovery journals, `gh-stack-rebase-state` and `gh-stack-modify-state`, also live in the common directory and record the worktrees involved. Git's own HEAD, index, rebase, and cherry-pick markers remain **per worktree**.

On upgrade, nonconflicting legacy worktree catalogs are consolidated automatically and originals are preserved as backups. Migration is a prerequisite and can complete even if the requested rewrite is subsequently refused. Conflicting definitions stop migration rather than choosing one; the error identifies the files to reconcile. Finish or abort legacy in-progress operations in their original worktree first. Do not mix old and new gh-stack versions within one clone.

### Git worktrees

You can keep independent stacks in linked worktrees or track a stack whose branches are distributed across them. **For now, `rebase` and `sync` require all stack branches to be unoccupied or checked out in the initiating worktree.** They conservatively refuse foreign-owned members, even outside a requested rebase range, before changing refs, checkouts, stack membership, or remote stacks. A foreign-owned trunk is also refused when trunk updates are enabled; `rebase --no-trunk` does not update it.

Mutations are serialized across the clone, while read-only views remain available. Paused operations must be continued or aborted before another mutation. Rebase recovery must be invoked in its recorded original worktree; invoking it elsewhere fails without changing either checkout. Modify recovery can be invoked elsewhere and still executes in its recorded origin. gh-stack never automatically stashes changes, creates/removes worktrees, or steals another checkout.

Mutation locks coordinate **gh-stack processes only**, not arbitrary Git commands, editors, or other tools. Keep affected worktrees idle while history is being rewritten. During a pause, make only the requested conflict-resolution edits and staging in the reported worktree.

Navigation to a branch checked out elsewhere reports its path and fails without switching. Add `--print-path` to `up`, `down`, `top`, `bottom`, `trunk`, or an explicit-target `checkout` to get the owning path instead. Unoccupied targets are checked out here before printing this worktree's path; successful stdout contains only the absolute path and a newline. See the [worktree workflow](docs/src/content/docs/guides/workflows.md#working-across-git-worktrees) for a shell wrapper that checks errors before changing directories.

**Temporary core limitation:** `modify` works inside a linked worktree only when all stack branches are unoccupied or checked out in that same worktree. Distributed modify is rejected before opening the TUI or applying changes. A trunk checked out elsewhere is allowed because modify only reads it.

With `git init --separate-git-dir`, Git may report the administration directory instead of the main working directory in its worktree list. Shared storage and operations from a known main or linked worktree still work, but discovering that main worktree's owner path from another checkout can be unavailable. Do not treat an administration-directory path as a checkout directory; use the actual main worktree when its location cannot be discovered.

## Commands

Expand All @@ -76,6 +94,8 @@ Initializes a new stack locally. In interactive mode (no arguments), prompts for

When explicit branch names are given, existing branches are adopted automatically and any missing branches are created. The trunk defaults to the repository's default branch unless overridden with `--base`.

An existing branch checked out in another worktree can be adopted without checking it out here. If the final branch is occupied elsewhere, `init` reports its owner and leaves your current checkout unchanged.

Enables `git rerere` automatically so that conflict resolutions are remembered across rebases.

| Flag | Description |
Expand Down Expand Up @@ -108,6 +128,8 @@ gh stack add [flags] [branch]

For an existing stack, creates a new branch at the current HEAD, adds it to the top of the stack, and checks it out. Must be run while on the topmost branch of a stack. If no branch name is given, prompts for one.

An existing branch checked out in another worktree can also be adopted without switching either checkout. Commit/stage shortcuts cannot be used to adopt a foreign-owned branch and fail before staging or changing stack membership.

When run interactively from a branch that is not part of a stack, `add` offers to initialize a new stack instead. The supplied or auto-generated branch name becomes the first layer; without one, the standard `init` prompts are used.

You can optionally stage changes and create a commit as part of the `add` flow. When `-m` is provided without an explicit branch name, the branch name is auto-generated in date+slug format (e.g., `03-24-add_login`).
Expand Down Expand Up @@ -205,6 +227,8 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file
| `--remote <name>` | Remote to fetch from (defaults to auto-detected remote) |
| `--committer-date-is-author-date` | Set the committer date to the author date during rebase. Alias: `--preserve-dates` |

Date-preserving rebases use Git's merge backend so the date setting survives conflicts. Resume with `gh stack rebase --continue`; continuation uses Git's saved settings rather than repeating start-only options.

| Argument | Description |
|----------|-------------|
| `[branch]` | Target branch (defaults to the current branch) |
Expand Down
Loading
Loading