diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 36a0e1c..5c17103 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 @@ -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 `Cmd(cfg *config.Config)` with logic in `run()`. -- `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 (`/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 @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 15815d2..af05959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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:** `/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` 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:** `/gh-stack.lock` protects short catalog saves; `/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/`) @@ -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). diff --git a/README.md b/README.md index e07a4aa..f406757 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `/gh-stack` (a JSON file, not committed to the repo), where `` 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 @@ -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 | @@ -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`). @@ -205,6 +227,8 @@ If a rebase conflict occurs, the operation pauses and prints the conflicted file | `--remote ` | 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) | diff --git a/cmd/add.go b/cmd/add.go index c8a5d38..ddcaa56 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,13 +1,13 @@ package cmd import ( + "errors" "fmt" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/branch" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" - "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -60,17 +60,25 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return ErrInvalidArgs } + release, err := beginStackMutation(cfg, "add") + if err != nil { + return err + } + defer release() + wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked + // An explicit foreign target is incompatible even with the empty-layer + // shortcut: never stage or commit before checking its ownership. + if wantsCommit && len(args) > 0 && args[0] != "" { + if err := rejectForeignCommitTarget(cfg, args[0]); err != nil { + return err + } + } result, err := loadStackOptional(cfg, "") if err != nil { - return ErrNotInStack + return stackLookupError(err) } gitDir := result.GitDir - if err := modify.CheckStateGuard(gitDir); err != nil { - cfg.Errorf("%s", err) - return ErrModifyRecovery - } - if result.Stack == nil { branchName, err := addBranchNameFromArgs(cfg, opts, args) if err != nil { @@ -101,7 +109,6 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // Check if the current branch is a stack branch with no unique commits // relative to its parent. If so, the commit should land on this branch // without creating a new one (e.g., right after init). - wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked var branchIsEmpty bool if wantsCommit && idx >= 0 { parentBranch := s.ActiveBaseBranch(currentBranch) @@ -172,6 +179,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Errorf("failed to check branch %s: %s", branchName, err) return ErrSilent } + owner, err := foreignWorktreePath(branchName) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" && wantsCommit { + reportWorktreeOwner(cfg, branchName, owner) + cfg.Errorf("commit and staging flags cannot be used when adopting a branch checked out in another worktree") + return ErrInvalidArgs + } var adoptedBase string if adopted { adoptedBase, err = git.MergeBase(currentBranch, branchName) @@ -197,9 +214,15 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } } - if err := git.CheckoutBranch(branchName); err != nil { - cfg.Errorf("failed to checkout branch: %s", err) - return ErrSilent + if owner == "" { + if err := checkoutWorktreeBranch(cfg, branchName, false); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } + cfg.Errorf("failed to checkout branch: %s", err) + return ErrSilent + } } base := adoptedBase @@ -223,7 +246,7 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { } if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) + return stackSaveError(cfg, err) } // Print summary @@ -241,10 +264,27 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { cfg.Successf("Created and checked out branch %q", branchName) } } + if owner != "" { + reportWorktreeOwner(cfg, branchName, owner) + } return nil } +func rejectForeignCommitTarget(cfg *config.Config, branchName string) error { + owner, err := foreignWorktreePath(branchName) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" { + reportWorktreeOwner(cfg, branchName, owner) + cfg.Errorf("commit and staging flags cannot be used with a branch checked out in another worktree") + return ErrInvalidArgs + } + return nil +} + func addBranchNameFromArgs(cfg *config.Config, opts *addOptions, args []string) (string, error) { if len(args) > 0 && args[0] != "" { return args[0], nil @@ -290,13 +330,18 @@ func initializeStackFromAdd(cfg *config.Config, opts *addOptions, branchName, cu } wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked + initOpts := &initOptions{} if wantsCommit { - if err := stageAndValidate(cfg, opts); err != nil { - return ErrSilent + initOpts.beforeCreate = func(target string) error { + if err := rejectForeignCommitTarget(cfg, target); err != nil { + return err + } + if err := stageAndValidate(cfg, opts); err != nil { + return ErrSilent + } + return nil } } - - initOpts := &initOptions{} if branchName != "" { initOpts.branches = []string{branchName} } diff --git a/cmd/add_test.go b/cmd/add_test.go index 469c6b1..ee55b24 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -8,11 +8,123 @@ import ( "github.com/AlecAivazis/survey/v2/terminal" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestAdd_ForeignAdoptionAndCommitPreflight(t *testing.T) { + tests := []struct { + name string + opts addOptions + empty bool + noStack bool + wantError bool + }{ + {name: "metadata adoption"}, + {name: "message", opts: addOptions{message: "commit"}, wantError: true}, + {name: "stage all", opts: addOptions{stageAll: true}, wantError: true}, + {name: "stage tracked", opts: addOptions{stageTracked: true}, wantError: true}, + {name: "empty layer shortcut", opts: addOptions{stageAll: true, message: "commit"}, empty: true, wantError: true}, + {name: "initialize from add", opts: addOptions{stageAll: true, message: "commit"}, noStack: true, wantError: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + common, local, root, owner := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + if !tt.noStack { + saveStack(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}}) + } + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "b2"}}, nil }, + RevParseMultiFn: func([]string) ([]string, error) { + if tt.empty { + return []string{"same", "same"}, nil + } + return []string{"parent", "current"}, nil + }, + MergeBaseFn: func(string, string) (string, error) { return "adopted-base", nil }, + StageAllFn: func() error { + t.Fatal("must reject before staging") + return nil + }, + StageTrackedFn: func() error { + t.Fatal("must reject before staging") + return nil + }, + CommitFn: func(string) (string, error) { + t.Fatal("must not commit in either worktree") + return "", nil + }, + CheckoutBranchFn: func(string) error { + t.Fatal("foreign adoption must not check out") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { return true, nil } + cfg.GitHubClientOverride = &github.MockClient{} + err := runAdd(cfg, &tt.opts, []string{"b2"}) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, owner) + sf, loadErr := stack.Load(common) + require.NoError(t, loadErr) + if tt.wantError { + assert.ErrorIs(t, err, ErrInvalidArgs) + if tt.noStack { + assert.Empty(t, sf.Stacks) + } else { + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b1"}, sf.Stacks[0].BranchNames()) + } + } else { + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames()) + assert.Equal(t, "adopted-base", sf.Stacks[0].Branches[1].Base) + assert.Contains(t, diagnostics, "Adopted") + assert.Contains(t, diagnostics, "left unchanged") + } + }) + } +} + +func TestAdd_InteractiveInitForeignTargetFailsBeforeStaging(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "foreign"}}, nil }, + StageAllFn: func() error { + t.Fatal("must reject the prompted foreign target before staging") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.ConfirmFn = func(string, bool) (bool, error) { return true, nil } + cfg.InputFn = func(string) (string, error) { return "foreign", nil } + cfg.GitHubClientOverride = &github.MockClient{} + require.ErrorIs(t, runAdd(cfg, &addOptions{stageAll: true}, nil), ErrInvalidArgs) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, owner) + sf, err := stack.Load(common) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + // saveStack is a helper to pre-create a stack file for add tests. func saveStack(t *testing.T, gitDir string, s stack.Stack) { t.Helper() diff --git a/cmd/checkout.go b/cmd/checkout.go index c15f2d6..ba926dc 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -18,7 +18,8 @@ import ( ) type checkoutOptions struct { - target string + target string + printPath bool } func CheckoutCmd(cfg *config.Config) *cobra.Command { @@ -67,12 +68,14 @@ omitted.`, $ gh stack checkout`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + opts.target = "" if len(args) > 0 { opts.target = args[0] } return runCheckout(cfg, opts) }, } + cmd.Flags().BoolVar(&opts.printPath, "print-path", false, "Print the target worktree path (requires an explicit target)") return cmd } @@ -82,10 +85,17 @@ omitted.`, // the GitHub API to discover remote stacks, then tries as a branch name. // Branch names resolve locally first and then against stacks on GitHub. func runCheckout(cfg *config.Config, opts *checkoutOptions) error { - gitDir, err := git.GitDir() + if opts.printPath { + if opts.target == "" { + cfg.Errorf("--print-path requires an explicit branch, stack number, PR number, or PR URL") + return ErrInvalidArgs + } + cfg = noninteractiveConfig(cfg) + cfg.WorktreePathOnly = true + } + gitDir, err := stackStateDir(cfg) if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err } sf, err := stack.Load(gitDir) @@ -127,6 +137,10 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { } } else { // Non-numeric target — resolve locally before checking GitHub. + if len(sf.FindAllStacksForBranch(opts.target)) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks; use a stack or PR number to choose one", opts.target) + return ErrDisambiguate + } var br *stack.BranchRef s, br, err = resolvePR(cfg, sf, opts.target) if err == nil { @@ -145,6 +159,9 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { } } + if opts.printPath { + return checkoutWorktreeBranch(cfg, targetBranch, true) + } currentBranch, _ := git.CurrentBranch() if targetBranch == currentBranch { cfg.Infof("Already on %s", targetBranch) @@ -152,7 +169,11 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } - if err := git.CheckoutBranch(targetBranch); err != nil { + if err := checkoutWorktreeBranch(cfg, targetBranch, false); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } cfg.Errorf("failed to checkout %s: %v", targetBranch, err) return ErrSilent } @@ -175,6 +196,11 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { // so a given number is only ever one object type; a number that is not a stack // simply misses at step 1 and resolves at a later step. func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, number int, raw string) (*stack.Stack, string, error) { + if cfg.WorktreePathOnly { + if local := stackResultByNumber(sf, gitDir, number); local != nil { + return local.Stack, topUnmergedBranch(local.Stack), nil + } + } // 1. Try as a stack number (the primary identifier). if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil { return s, targetBranch, nil @@ -200,6 +226,10 @@ func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string // 4. Fall back to local branch name lookup (handles numeric branch names). stacks := sf.FindAllStacksForBranch(raw) + if len(stacks) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks; use a stack or PR number to choose one", raw) + return nil, "", ErrDisambiguate + } if len(stacks) > 0 { s := stacks[0] idx := s.IndexOf(raw) @@ -397,6 +427,31 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, return nil, "", ErrSilent } + if cfg.WorktreePathOnly { + owner, err := foreignWorktreePath(targetBranch) + if err != nil { + cfg.Errorf("%s", err) + return nil, "", ErrSilent + } + if owner != "" { + // Resolving the owner needs no import, fetch, or catalog write. + return &stack.Stack{Trunk: stack.BranchRef{Branch: trunk}}, targetBranch, nil + } + } + release, err := beginStackMutation(cfg, "checkout") + if err != nil { + return nil, "", err + } + defer release() + // The remote lookup or picker may have taken time. Reload after acquiring + // the clone-wide lock instead of applying an import to a stale snapshot. + fresh, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("loading stack state: %s", err) + return nil, "", ErrNotInStack + } + *sf = *fresh + remoteStackID := strconv.Itoa(remoteStack.ID) // Check if the target branch is already in a local stack. @@ -417,9 +472,9 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, localStack.ID = remoteStackID localStack.Number = remoteStack.Number if err := stack.Save(gitDir, sf); err != nil { - return nil, "", handleSaveError(cfg, err) + return nil, "", stackSaveError(cfg, err) } - cfg.Successf("Local stack matches remote — switching to branch%s", stackLabel(remoteStack.Number)) + cfg.Successf("Local stack matches remote%s", stackLabel(remoteStack.Number)) return localStack, targetBranch, nil } @@ -446,7 +501,7 @@ func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, } if err := stack.Save(gitDir, sf); err != nil { - return nil, "", handleSaveError(cfg, err) + return nil, "", stackSaveError(cfg, err) } return s, targetBranch, nil @@ -572,7 +627,7 @@ func handleCompositionConflict( return nil, importErr } if err := stack.Save(gitDir, sf); err != nil { - return nil, handleSaveError(cfg, err) + return nil, stackSaveError(cfg, err) } cfg.Successf("Local stack replaced with remote version") return s, nil @@ -599,7 +654,7 @@ func handleCompositionConflict( localStack.ID = "" localStack.Number = 0 if err := stack.Save(gitDir, sf); err != nil { - return nil, handleSaveError(cfg, err) + return nil, stackSaveError(cfg, err) } return localStack, nil diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index 40b4935..1ef176e 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -2,6 +2,9 @@ package cmd import ( "fmt" + "io" + "os" + "path/filepath" "testing" "github.com/cli/go-gh/v2/pkg/api" @@ -13,6 +16,176 @@ import ( "github.com/stretchr/testify/require" ) +func TestCheckout_PrintPathRequiresTarget(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cmd := CheckoutCmd(cfg) + cmd.SetArgs([]string{"--print-path"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + assert.ErrorIs(t, cmd.Execute(), ErrInvalidArgs) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, "explicit") +} + +func TestCheckout_PrintPathAmbiguousBranch(t *testing.T) { + common := t.TempDir() + writeStackFileMulti(t, common, + stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "one"}}}, + stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "two"}}}, + ) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + assert.ErrorIs(t, runCheckout(cfg, &checkoutOptions{target: "main", printPath: true}), ErrDisambiguate) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, "multiple stacks") +} + +func TestCheckout_RemotePrintPathDoesNotImportDuringRecovery(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(common, rebaseStateFile), []byte(`{"worktrees":{}}`), 0600)) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "remote"}}, nil }, + FetchFn: func(string) error { + t.Fatal("read-only path resolution must not fetch") + return nil + }, + CheckoutBranchFn: func(string) error { + t.Fatal("read-only path resolution must not check out") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{Number: 7, PullRequests: []int{10}}, nil + }, + FindPRByNumberFn: func(int) (*github.PullRequest, error) { + return &github.PullRequest{Number: 10, BaseRefName: "main", HeadRefName: "remote"}, nil + }, + } + require.NoError(t, runCheckout(cfg, &checkoutOptions{target: "7", printPath: true})) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Equal(t, owner+"\n", out) + assert.NoFileExists(t, filepath.Join(common, "gh-stack")) +} + +func TestCheckout_RealLinkedWorktreeFromSubdirectory(t *testing.T) { + root := t.TempDir() + owner := filepath.Join(t.TempDir(), "linked owner's tree") + issue250Git(t, root, "init", "-b", "main") + issue250Git(t, root, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial") + issue250Git(t, root, "branch", "local") + issue250Git(t, root, "branch", "unoccupied") + issue250Git(t, root, "worktree", "add", "-b", "foreign", owner) + issue250Git(t, root, "checkout", "local") + subdir := filepath.Join(root, "nested", "directory") + require.NoError(t, os.MkdirAll(subdir, 0700)) + withIssue250Repo(t, subdir) + common, err := git.CommonDir() + require.NoError(t, err) + writeStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "local"}, {Branch: "foreign"}, {Branch: "unoccupied"}}, + }) + actualRoot, err := git.RootDir() + require.NoError(t, err) + actualOwner, err := requireWorktree(t, git.CurrentOps(), owner).RootDir() + require.NoError(t, err) + + cfg, outR, errR := config.NewTestConfig() + assert.ErrorIs(t, runCheckout(cfg, &checkoutOptions{target: "foreign"}), ErrInvalidArgs) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, actualOwner) + assert.Equal(t, "local", issue250Git(t, root, "branch", "--show-current")) + assert.Equal(t, "foreign", issue250Git(t, owner, "branch", "--show-current")) + + cfg, outR, errR = config.NewTestConfig() + require.NoError(t, runCheckout(cfg, &checkoutOptions{target: "foreign", printPath: true})) + out, _ = commandOutput(t, cfg, outR, errR) + assert.Equal(t, actualOwner+"\n", out) + assert.Equal(t, "local", issue250Git(t, root, "branch", "--show-current")) + + cfg, outR, errR = config.NewTestConfig() + require.NoError(t, runCheckout(cfg, &checkoutOptions{target: "unoccupied", printPath: true})) + out, _ = commandOutput(t, cfg, outR, errR) + assert.Equal(t, actualRoot+"\n", out) + assert.Equal(t, "unoccupied", issue250Git(t, root, "branch", "--show-current")) + assert.Equal(t, "foreign", issue250Git(t, owner, "branch", "--show-current")) +} + +func TestCheckout_SeparateGitDirMainOwner(t *testing.T) { + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "safe.bareRepository") + t.Setenv("GIT_CONFIG_VALUE_0", "explicit") + root, admin := t.TempDir(), filepath.Join(t.TempDir(), "git administration") + linked := filepath.Join(t.TempDir(), "linked worktree") + issue250Git(t, root, "init", "--separate-git-dir", admin, "-b", "main") + issue250Git(t, root, "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial") + issue250Git(t, root, "worktree", "add", "-b", "linked", linked) + withIssue250Repo(t, root) + common, err := git.CommonDir() + require.NoError(t, err) + actualRoot, err := git.RootDir() + require.NoError(t, err) + writeStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "linked"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + require.NoError(t, runTrunkWithPath(cfg, true)) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Equal(t, actualRoot+"\n", out, "the main worktree can use its observed root") + + withIssue250Repo(t, linked) + trees, err := git.Worktrees() + require.NoError(t, err) + var mainPath string + for _, tree := range trees { + if tree.Branch == "main" { + mainPath = tree.Path + } + } + require.NotEmpty(t, mainPath) + commonInfo, err := os.Stat(common) + require.NoError(t, err) + mainInfo, err := os.Stat(mainPath) + require.NoError(t, err) + adminOnly := os.SameFile(commonInfo, mainInfo) + + cfg, outR, errR = config.NewTestConfig() + err = runTrunkWithPath(cfg, true) + out, diagnostics := commandOutput(t, cfg, outR, errR) + if adminOnly { + assert.Error(t, err) + assert.Empty(t, out, "an administrative directory is not a navigable worktree") + assert.Contains(t, diagnostics, "separate Git directory") + assert.Contains(t, diagnostics, "main worktree") + } else { + require.NoError(t, err, diagnostics) + assert.Equal(t, mainPath+"\n", out) + } + assert.Equal(t, "main", issue250Git(t, root, "branch", "--show-current")) + assert.Equal(t, "linked", issue250Git(t, linked, "branch", "--show-current")) + + issue250Git(t, root, "config", "core.worktree", actualRoot) + cfg, outR, errR = config.NewTestConfig() + require.NoError(t, runTrunkWithPath(cfg, true)) + out, _ = commandOutput(t, cfg, outR, errR) + assert.Equal(t, actualRoot+"\n", out, "a configured backlink identifies the main worktree from a linked caller") + assert.Equal(t, "main", issue250Git(t, root, "branch", "--show-current")) + assert.Equal(t, "linked", issue250Git(t, linked, "branch", "--show-current")) +} + func TestCheckout_ByBranchName(t *testing.T) { gitDir := t.TempDir() var checkedOut string diff --git a/cmd/init.go b/cmd/init.go index b1ed803..35af9ea 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -13,9 +13,10 @@ import ( ) type initOptions struct { - branches []string - base string - adopt bool // deprecated, kept for backward compat + branches []string + base string + adopt bool // deprecated, kept for backward compat + beforeCreate func(string) error } func InitCmd(cfg *config.Config) *cobra.Command { @@ -57,10 +58,24 @@ Use --base to specify a different trunk branch.`, } func runInit(cfg *config.Config, opts *initOptions) error { - gitDir, err := git.GitDir() + if len(opts.branches) == 0 && !cfg.IsInteractive() { + cfg.Errorf("interactive input required; provide branch names as arguments") + return ErrInvalidArgs + } + for _, name := range opts.branches { + if err := git.ValidateRefName(name); err != nil { + cfg.Errorf("invalid branch name %q: must be a valid git ref", name) + return ErrInvalidArgs + } + } + release, err := beginStackMutation(cfg, "init") if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err + } + defer release() + gitDir, err := stackStateDir(cfg) + if err != nil { + return err } // Determine trunk branch @@ -157,7 +172,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } var interactiveAdopted bool - branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, trunkRef, currentBranch) + branches, interactiveAdopted, err = runInteractiveInitWithPreflight(cfg, sf, trunk, trunkRef, currentBranch, opts.beforeCreate) if err != nil { return err } @@ -210,23 +225,32 @@ func runInit(cfg *config.Config, opts *initOptions) error { } } - if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) - } - - // --- Output: switch to top branch + "What's next" --- - + // Complete the checkout before publishing the catalog. Adoption of an + // occupied branch is metadata-only and must leave both worktrees alone. lastBranch := branches[len(branches)-1] - if currentBranch != lastBranch { - if err := git.CheckoutBranch(lastBranch); err != nil { + owner, err := foreignWorktreePath(lastBranch) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner == "" && currentBranch != lastBranch { + if err := checkoutWorktreeBranch(cfg, lastBranch, false); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } cfg.Errorf("switching to branch %s: %s", lastBranch, err) return ErrSilent } } + if err := stack.Save(gitDir, sf); err != nil { + return stackSaveError(cfg, err) + } + hasAdopted := len(adopted) > 0 - printWhatsNext(cfg, &newStack, branches, hasAdopted, prCount) + printWhatsNextInWorktree(cfg, &newStack, branches, hasAdopted, prCount, owner) return nil } @@ -263,6 +287,11 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi resolved = append(resolved, branchInfo{name: b, exists: exists}) } + if opts.beforeCreate != nil && len(resolved) > 0 { + if err := opts.beforeCreate(resolved[len(resolved)-1].name); err != nil { + return nil, nil, err + } + } // Phase 2: create missing branches branches := make([]string, 0, len(resolved)) @@ -290,6 +319,10 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi // one. Returns the branches and whether the branch was adopted (already // existed). func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, trunkRef, currentBranch string) ([]string, bool, error) { + return runInteractiveInitWithPreflight(cfg, sf, trunk, trunkRef, currentBranch, nil) +} + +func runInteractiveInitWithPreflight(cfg *config.Config, sf *stack.StackFile, trunk, trunkRef, currentBranch string, beforeCreate func(string) error) ([]string, bool, error) { p := prompter.New(cfg.In, cfg.Out, cfg.Err) cfg.Printf("Initializing a stack from %s.", trunk) @@ -359,6 +392,11 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, trunkRef cfg.Errorf("failed to check branch %s: %s", branchName, err) return nil, false, ErrSilent } + if beforeCreate != nil { + if err := beforeCreate(branchName); err != nil { + return nil, false, err + } + } if exists { wasAdopted = true } else { @@ -392,6 +430,10 @@ func promptBranchName(cfg *config.Config) (string, error) { // printWhatsNext prints the scenario-aware "What's next" block after init. func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int) { + printWhatsNextInWorktree(cfg, s, branches, hasAdopted, prCount, "") +} + +func printWhatsNextInWorktree(cfg *config.Config, s *stack.Stack, branches []string, hasAdopted bool, prCount int, owner string) { lastBranch := branches[len(branches)-1] // Build the chain: main ← branch1 ← branch2 @@ -410,7 +452,11 @@ func printWhatsNext(cfg *config.Config, s *stack.Stack, branches []string, hasAd } // Position - cfg.Printf(" You're on %s (top of stack).", lastBranch) + if owner != "" { + reportWorktreeOwner(cfg, lastBranch, owner) + } else { + cfg.Printf(" You're on %s (top of stack).", lastBranch) + } // PR summary (only when adopting and at least one PR found) if hasAdopted && prCount > 0 { diff --git a/cmd/init_test.go b/cmd/init_test.go index cbd4d0e..95e8c78 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "testing" "github.com/github/gh-stack/internal/config" @@ -37,6 +38,55 @@ func TestInit_BranchLookupFailureBeforeCreation(t *testing.T) { assert.Contains(t, collectOutput(cfg, outR, errR), lookupErr.Error()) } +func TestInit_AdoptsForeignBranchInSharedCatalog(t *testing.T) { + common, local, root, owner := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "independent"}}}) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "foreign"}}, nil }, + CheckoutBranchFn: func(string) error { + t.Fatal("adoption must not move either checkout") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + require.NoError(t, runInit(cfg, &initOptions{base: "main", branches: []string{"foreign"}})) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, owner) + assert.Contains(t, diagnostics, "left unchanged") + assert.NotContains(t, diagnostics, "You're on foreign") + sf, err := stack.Load(common) + require.NoError(t, err) + require.Len(t, sf.Stacks, 2) + assert.Equal(t, []string{"foreign"}, sf.Stacks[1].BranchNames()) + assert.NoFileExists(t, filepath.Join(local, "gh-stack")) +} + +func TestInit_CheckoutFailureDoesNotPublishStack(t *testing.T) { + common := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + CheckoutBranchFn: func(string) error { return assert.AnError }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + require.Error(t, runInit(cfg, &initOptions{base: "main", branches: []string{"branch"}})) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.NotContains(t, diagnostics, "Created stack") + sf, err := stack.Load(common) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + // collectOutput closes the write ends of the test config pipes and returns // the captured stderr content. Shared across cmd test files. func collectOutput(cfg *config.Config, outR, errR *os.File) string { diff --git a/cmd/link.go b/cmd/link.go index c18baa4..03f9e0d 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -99,6 +99,12 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error { return ErrInvalidArgs } + release, err := beginOptionalStackMutation(cfg, "link") + if err != nil { + return err + } + defer release() + client, err := cfg.GitHubClient() if err != nil { cfg.Errorf("failed to create GitHub client: %s", err) diff --git a/cmd/link_test.go b/cmd/link_test.go index e54fcdf..f377ae5 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -24,6 +24,7 @@ func newLinkGitMock(branches ...string) *git.MockOps { branchSet[b] = true } return &git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(name string) (bool, error) { return branchSet[name], nil }, PushFn: func(string, []string, bool, bool) error { return nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, @@ -65,7 +66,9 @@ func TestLink_PRIdentifiersOutsideRepository(t *testing.T) { func TestLink_UnexpectedBranchLookupFailureStopsBeforePush(t *testing.T) { lookupErr := fmt.Errorf("selected Git directory changed") + dir := t.TempDir() restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return dir, nil }, BranchExistsFn: func(string) (bool, error) { return false, lookupErr }, PushFn: func(string, []string, bool, bool) error { t.Fatal("must not push after a failed lookup") @@ -218,6 +221,7 @@ func TestLink_PRNumbers_ExactMatch_NoOp(t *testing.T) { } func TestLink_PRNumbers_WouldRemovePRs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -251,6 +255,7 @@ func TestLink_PRNumbers_WouldRemovePRs(t *testing.T) { } func TestLink_PRNumbers_MultipleStacks(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -283,6 +288,7 @@ func TestLink_PRNumbers_MultipleStacks(t *testing.T) { } func TestLink_TooFewArgs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} @@ -297,6 +303,7 @@ func TestLink_TooFewArgs(t *testing.T) { } func TestLink_DuplicateArgs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} @@ -315,6 +322,7 @@ func TestLink_DuplicateArgs(t *testing.T) { } func TestLink_StacksUnavailable(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() setTestRepo(cfg) cfg.GitHubClientOverride = &github.MockClient{ @@ -377,6 +385,7 @@ func TestLink_Create422(t *testing.T) { // --- PR eligibility tests --- func TestLink_RejectsMergedPR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -407,6 +416,7 @@ func TestLink_RejectsMergedPR(t *testing.T) { } func TestLink_RejectsClosedPR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -436,6 +446,7 @@ func TestLink_RejectsClosedPR(t *testing.T) { } func TestLink_RejectsQueuedPR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -476,6 +487,7 @@ func TestLink_RejectsQueuedPR(t *testing.T) { } func TestLink_RejectsAutoMergeEnabledPR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -593,6 +605,7 @@ func TestLink_RejectsAutoMergePR_ByBranch(t *testing.T) { } func TestLink_ReportsMultipleIneligiblePRs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -790,6 +803,7 @@ func TestLink_AllowsAutoMergePRAlreadyInStack(t *testing.T) { // exemption is scoped correctly: a queued PR that is NOT already a member of the // matched stack is still rejected, even when the command targets that stack. func TestLink_RejectsQueuedPRNotInStack_WhenAddingToExistingStack(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -1385,6 +1399,7 @@ func TestLink_FixesBaseBranches(t *testing.T) { func TestLink_DefaultBase_RetargetsBottomPRToDefaultBranch(t *testing.T) { defaultBranchCalled := false restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(string) (bool, error) { return false, nil }, DefaultBranchFn: func() (string, error) { defaultBranchCalled = true @@ -1450,6 +1465,7 @@ func TestLink_DefaultBase_RetargetsBottomPRToDefaultBranch(t *testing.T) { // omitted, rather than a hardcoded "main". func TestLink_DefaultBase_CreatesBottomPROnDefaultBranch(t *testing.T) { restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(name string) (bool, error) { return name == "feat-a" || name == "feat-b", nil }, PushFn: func(string, []string, bool, bool) error { return nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, @@ -1495,6 +1511,7 @@ func TestLink_DefaultBase_CreatesBottomPROnDefaultBranch(t *testing.T) { // determined. func TestLink_DefaultBase_ErrorWhenUnresolvable(t *testing.T) { restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(string) (bool, error) { return false, nil }, DefaultBranchFn: func() (string, error) { return "", fmt.Errorf("no default branch") }, }) @@ -1529,6 +1546,7 @@ func TestLink_DefaultBase_ErrorWhenUnresolvable(t *testing.T) { func TestLink_ExplicitBase_SkipsDefaultBranchResolution(t *testing.T) { defaultBranchCalled := false restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(string) (bool, error) { return false, nil }, DefaultBranchFn: func() (string, error) { defaultBranchCalled = true @@ -1588,6 +1606,7 @@ func TestLink_ExplicitBase_SkipsDefaultBranchResolution(t *testing.T) { } func TestLink_DuplicateBranchResolvesToSamePR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { @@ -1656,6 +1675,7 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) { var pushedRemote string restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(name string) (bool, error) { return name == "feat-a" || name == "feat-b", nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, PushFn: func(remote string, branches []string, force, atomic bool) error { @@ -1703,6 +1723,7 @@ func TestLink_RemoteFlag(t *testing.T) { var pushedRemote string restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(string) (bool, error) { return true, nil }, PushFn: func(remote string, branches []string, force, atomic bool) error { pushedRemote = remote @@ -1742,6 +1763,7 @@ func TestLink_SkipsPushForPRNumbersOnly(t *testing.T) { pushCalled := false restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, BranchExistsFn: func(string) (bool, error) { return false, nil }, // PR numbers aren't local branches PushFn: func(string, []string, bool, bool) error { pushCalled = true @@ -1928,6 +1950,7 @@ func TestFormatAPIError(t *testing.T) { } func TestLink_FindPRByNumber_ErrorIsFatal(t *testing.T) { + defer mockRemoteOnlyGit()() // When FindPRByNumber returns an error (not just nil), it should NOT // silently fall through to branch-name lookup. cfg, _, errR := config.NewTestConfig() @@ -2105,6 +2128,7 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) { // When using PR numbers (no local repo context), no template is found // and the footer should be present for newly created PRs. mock := &git.MockOps{ + GitDirFn: func() (string, error) { return "", fmt.Errorf("not a git repository") }, RootDirFn: func() (string, error) { return "", fmt.Errorf("not in a git repo") }, @@ -2199,6 +2223,7 @@ func TestLink_PRURLs_CreateNewStack(t *testing.T) { } func TestLink_PRURLs_NotFound(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2285,6 +2310,7 @@ func linkRemoteStack(number int, details ...github.RemoteStackPR) github.RemoteS } func TestLink_AddMode_AppendsPRNumberToStack(t *testing.T) { + defer mockRemoteOnlyGit()() var addNumber int var addPRs []int cfg, _, errR := config.NewTestConfig() @@ -2375,6 +2401,7 @@ func TestLink_AddMode_CreatesPRForBranchOnTopOfStack(t *testing.T) { } func TestLink_AddMode_IdempotentWhenAllPresent(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2411,6 +2438,7 @@ func TestLink_AddMode_IdempotentWhenAllPresent(t *testing.T) { } func TestLink_AddMode_SkipsPresentAppendsNew(t *testing.T) { + defer mockRemoteOnlyGit()() var addPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -2453,6 +2481,7 @@ func TestLink_AddMode_SkipsPresentAppendsNew(t *testing.T) { } func TestLink_AddMode_RejectsPRFromAnotherStack(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2489,6 +2518,7 @@ func TestLink_AddMode_RejectsPRFromAnotherStack(t *testing.T) { } func TestLink_AddMode_RejectsIneligibleNewPR(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2529,6 +2559,7 @@ func TestLink_AddMode_RejectsIneligibleNewPR(t *testing.T) { } func TestLink_AddMode_ExemptsIneligibleExistingMember(t *testing.T) { + defer mockRemoteOnlyGit()() var addPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -2618,6 +2649,7 @@ func TestLink_NumericFirstArgNotAStack_UsesCreateMode(t *testing.T) { } func TestLink_AddMode_WarnsWhenBaseFlagSet(t *testing.T) { + defer mockRemoteOnlyGit()() var addPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -2704,6 +2736,7 @@ func TestLink_AddMode_ChainsMultipleCreatedPRs(t *testing.T) { } func TestLink_AddMode_AddToStack422(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2739,6 +2772,7 @@ func TestLink_AddMode_AddToStack422(t *testing.T) { } func TestLink_AddMode_AddToStack404_StackGone(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { @@ -2773,6 +2807,7 @@ func TestLink_AddMode_AddToStack404_StackGone(t *testing.T) { } func TestLink_AddMode_FetchesFullStackWhenListLacksHeadRefs(t *testing.T) { + defer mockRemoteOnlyGit()() var addPRs []int var getStackCalls int cfg, _, errR := config.NewTestConfig() diff --git a/cmd/merge.go b/cmd/merge.go index be61147..e8e6741 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -100,6 +100,17 @@ func runMerge(cfg *config.Config, opts *mergeOptions, args []string) error { cfg.Errorf("%s", err) return ErrInvalidArgs } + if len(args) > 0 { + if n, err := strconv.Atoi(strings.TrimSpace(args[0])); err != nil || n <= 0 { + cfg.Errorf("invalid argument %q: expected a stack number or pull request number", args[0]) + return ErrInvalidArgs + } + } + release, err := beginOptionalStackMutation(cfg, "merge") + if err != nil { + return err + } + defer release() client, err := cfg.GitHubClient() if err != nil { @@ -235,10 +246,9 @@ func resolveMergeStack(cfg *config.Config, client github.ClientOps, args []strin // resolveActiveRemoteStack reads only the local stack number for the current // branch, then fetches the full stack (and its PR states) from GitHub. func resolveActiveRemoteStack(cfg *config.Config, client github.ClientOps) (*github.RemoteStack, error) { - gitDir, err := git.GitDir() + gitDir, err := stackStateDir(cfg) if err != nil { - cfg.Errorf("not a git repository") - return nil, ErrNotInStack + return nil, err } sf, err := stack.Load(gitDir) if err != nil { diff --git a/cmd/merge_test.go b/cmd/merge_test.go index e9e6360..11a7c7b 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -110,6 +110,7 @@ func TestRunMerge_NoArg_MergesWholeStack(t *testing.T) { } func TestRunMerge_StackNumberArg(t *testing.T) { + defer mockRemoteOnlyGit()() var gotPR int gotAction := "unset" cfg, outR, errR := config.NewTestConfig() @@ -134,6 +135,7 @@ func TestRunMerge_StackNumberArg(t *testing.T) { } func TestRunMerge_MergeQueue_Headless(t *testing.T) { + defer mockRemoteOnlyGit()() gotMethod, gotAction := "unset", "unset" cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -163,6 +165,7 @@ func TestRunMerge_MergeQueue_Headless(t *testing.T) { } func TestRunMerge_MergeQueue_IgnoresMethodFlag(t *testing.T) { + defer mockRemoteOnlyGit()() gotMethod, gotAction := "unset", "unset" cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -191,6 +194,7 @@ func TestRunMerge_MergeQueue_IgnoresMethodFlag(t *testing.T) { } func TestRunMerge_MergeQueueDetectionError_FallsBackToDirect(t *testing.T) { + defer mockRemoteOnlyGit()() gotMethod, gotAction := "unset", "unset" cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -222,6 +226,7 @@ func TestRunMerge_MergeQueueDetectionError_FallsBackToDirect(t *testing.T) { } func TestRunMerge_PRNumberArg(t *testing.T) { + defer mockRemoteOnlyGit()() var gotPR int cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -248,6 +253,7 @@ func TestRunMerge_PRNumberArg(t *testing.T) { } func TestRunMerge_SquashFlag(t *testing.T) { + defer mockRemoteOnlyGit()() var gotMethod string cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -270,6 +276,7 @@ func TestRunMerge_SquashFlag(t *testing.T) { } func TestRunMerge_ConflictingMethodFlags(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() opts := fastOptions() opts.squash = true @@ -283,6 +290,7 @@ func TestRunMerge_ConflictingMethodFlags(t *testing.T) { } func TestRunMerge_InvalidMergeMethod(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() opts := fastOptions() opts.mergeMethod = "fast-forward" @@ -295,6 +303,7 @@ func TestRunMerge_InvalidMergeMethod(t *testing.T) { } func TestRunMerge_DisallowedMethod(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -315,6 +324,7 @@ func TestRunMerge_DisallowedMethod(t *testing.T) { } func TestRunMerge_DraftTarget(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, @@ -331,6 +341,7 @@ func TestRunMerge_DraftTarget(t *testing.T) { } func TestRunMerge_BlockerBelowTarget(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, @@ -347,6 +358,7 @@ func TestRunMerge_BlockerBelowTarget(t *testing.T) { } func TestRunMerge_AlreadyMergedTarget(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, @@ -363,6 +375,7 @@ func TestRunMerge_AlreadyMergedTarget(t *testing.T) { } func TestRunMerge_WholeStackBlockedByDraft(t *testing.T) { + defer mockRemoteOnlyGit()() submitCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -405,6 +418,7 @@ func TestRunMerge_NothingToMerge_AllMerged(t *testing.T) { } func TestRunMerge_SubmitNotMergeable(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -424,6 +438,7 @@ func TestRunMerge_SubmitNotMergeable(t *testing.T) { } func TestRunMerge_PollFailedConflict(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -446,6 +461,7 @@ func TestRunMerge_PollFailedConflict(t *testing.T) { } func TestRunMerge_AlreadyMergedOnSubmit(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -464,6 +480,7 @@ func TestRunMerge_AlreadyMergedOnSubmit(t *testing.T) { } func TestRunMerge_Enqueued(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -485,6 +502,7 @@ func TestRunMerge_Enqueued(t *testing.T) { } func TestRunMerge_EnqueuedOnSubmit(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -503,6 +521,7 @@ func TestRunMerge_EnqueuedOnSubmit(t *testing.T) { } func TestRunMerge_AsyncMergeUnavailable(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { @@ -521,6 +540,7 @@ func TestRunMerge_AsyncMergeUnavailable(t *testing.T) { } func TestRunMerge_StacksUnavailable(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ GetStackFn: func(n int) (*github.RemoteStack, error) { return nil, notFoundErr() }, @@ -549,6 +569,7 @@ func TestRunMerge_NoArg_NotInStack(t *testing.T) { } func TestRunMerge_DefaultMethodFallsBackToAllowed(t *testing.T) { + defer mockRemoteOnlyGit()() var gotMethod string cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ diff --git a/cmd/modify.go b/cmd/modify.go index 7eac798..12b6d4b 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -63,6 +63,16 @@ afterward to push changes, update PRs, and recreate the stack on GitHub.`, } func runModify(cfg *config.Config) error { + if !cfg.IsInteractive() { + cfg.Errorf("modify requires an interactive terminal") + return ErrSilent + } + cleanup, err := beginStackMutation(cfg, "modify") + if err != nil { + return err + } + defer cleanup() + // Run all precondition checks result, err := checkModifyPreconditions(cfg) if err != nil { @@ -135,6 +145,11 @@ func runModify(cfg *config.Config) error { for i, n := range applyNodes { reordered[len(applyNodes)-1-i] = n } + if branch, err := git.CurrentBranch(); err != nil { + return fmt.Errorf("rechecking modify checkout: %w", err) + } else if branch != currentBranch { + return fmt.Errorf("the current branch changed while modify was open; reopen modify before applying") + } applyResult, conflict, applyErr := modify.ApplyPlan(cfg, gitDir, s, sf, reordered, currentBranch, updateBaseSHAs) @@ -146,7 +161,18 @@ func runModify(cfg *config.Config) error { cfg.Warningf("Rebasing %s — conflict", conflict.Branch) } - printConflictDetailsWithContinue(cfg, conflict.Branch, "gh stack modify --continue") + state, err := modify.LoadState(gitDir) + if err != nil { + return fmt.Errorf("reading modify conflict location: %w", err) + } + if state == nil || state.Worktrees == nil { + return fmt.Errorf("modify conflict has no recorded worktree; recovery state was retained") + } + ops, err := state.Worktrees.OriginOps() + if err != nil { + return err + } + printConflictDetailsAt(cfg, ops, state.Worktrees.Origin.Path, conflict.Branch, "gh stack modify --continue") cfg.Printf("") cfg.Printf("Or restore the stack to its pre-modify state with `%s`", @@ -201,12 +227,16 @@ func printModifySuccess(cfg *config.Config, result *modifyview.ApplyResult) { // runModifyAbort handles recovery to a pre-modify state. func runModifyAbort(cfg *config.Config) error { - gitDir, err := git.GitDir() + cleanup, err := beginStackMutation(cfg, "modify-abort") if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err } + defer cleanup() + gitDir, err := stackStateDir(cfg) + if err != nil { + return err + } state, err := modify.LoadState(gitDir) if err != nil { cfg.Errorf("failed to read modify state: %s", err) @@ -228,10 +258,8 @@ func runModifyAbort(cfg *config.Config) error { cfg.Printf("Restoring stack to pre-modify state...") if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { cfg.Errorf("recovery failed: %s", err) - cfg.Printf("The stack may be in an inconsistent state.") - cfg.Printf("Try `%s` to fix, or `%s` + `%s` to recreate.", - cfg.ColorCyan("gh stack rebase"), cfg.ColorCyan("gh stack unstack --local"), - cfg.ColorCyan("gh stack init")) + cfg.Printf("Recovery state was retained. Resolve the reported problem and retry `%s`.", + cfg.ColorCyan("gh stack modify --abort")) return ErrSilent } cfg.Successf("Stack restored successfully") @@ -245,18 +273,22 @@ func runModifyAbort(cfg *config.Config) error { default: cfg.Errorf("unexpected modify state phase: %s", state.Phase) - cfg.Printf("Clearing invalid state file...") - modify.ClearState(gitDir) - return nil + cfg.Printf("Recovery state was retained") + return ErrModifyRecovery } } // runModifyContinue continues applying after the user resolves a rebase conflict. func runModifyContinue(cfg *config.Config) error { - gitDir, err := git.GitDir() + cleanup, err := beginStackMutation(cfg, "modify-continue") if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err + } + defer cleanup() + + gitDir, err := stackStateDir(cfg) + if err != nil { + return err } if err := modify.ContinueApply(cfg, gitDir, updateBaseSHAs); err != nil { @@ -280,7 +312,7 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) { result, err := loadStack(cfg, "") if err != nil { - return nil, ErrNotInStack + return nil, err } gitDir := result.GitDir @@ -314,6 +346,10 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) { cfg.Printf("Commit or stash your changes before running modify") return nil, ErrSilent } + if _, err := modify.CheckWorktrees(s); err != nil { + cfg.Errorf("%s", err) + return nil, ErrSilent + } // Ensure trunk branch exists locally (it may be absent if the user // renamed their initial branch before starting the stack). @@ -361,8 +397,8 @@ func checkModifyPreconditions(cfg *config.Config) (*loadStackResult, error) { func checkNoModifyInProgress(cfg *config.Config, gitDir string) error { state, err := modify.LoadState(gitDir) if err != nil { - cfg.Warningf("failed to read modify state: %v", err) - return nil + cfg.Errorf("failed to read modify state: %v", err) + return ErrModifyRecovery } if state == nil { return nil diff --git a/cmd/modify_test.go b/cmd/modify_test.go index 0b31348..f0d2d56 100644 --- a/cmd/modify_test.go +++ b/cmd/modify_test.go @@ -19,6 +19,7 @@ import ( "github.com/github/gh-stack/internal/stack" "github.com/github/gh-stack/internal/tui/modifyview" "github.com/github/gh-stack/internal/tui/stackview" + "github.com/github/gh-stack/internal/worktree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -490,9 +491,7 @@ func TestBuildModifyPlan(t *testing.T) { } plan := modify.BuildPlan(nodes) - // b1 is Removed=true so it's skipped in the main loop. - // b2 has no changes and is at its original position → nothing. - assert.Empty(t, plan, "removed nodes are skipped; unchanged nodes produce nothing") + assert.Equal(t, []modify.Action{{Type: "drop", Branch: "b1"}}, plan) }) t.Run("rename action", func(t *testing.T) { @@ -537,11 +536,11 @@ func TestBuildModifyPlan(t *testing.T) { } plan := modify.BuildPlan(nodes) - // b1 has a rename action → included - // b2 and b3 are Removed → skipped in the loop - require.Len(t, plan, 1) + require.Len(t, plan, 3) assert.Equal(t, "rename", plan[0].Type) assert.Equal(t, "feature-1", plan[0].NewName) + assert.Equal(t, "drop", plan[1].Type) + assert.Equal(t, "fold_down", plan[2].Type) }) t.Run("no changes produces empty plan", func(t *testing.T) { @@ -822,11 +821,9 @@ func TestModifyStateRoundTrip_WithPriorStackID(t *testing.T) { // 7. checkModifyStateGuard edge cases // --------------------------------------------------------------------------- -func TestCheckModifyStateGuard_IgnoresReadErrors(t *testing.T) { - // Use a path that doesn't exist and isn't a directory — this tests - // the "ignore read errors" branch in checkModifyStateGuard. - err := modify.CheckStateGuard("/nonexistent/path/that/does/not/exist") - assert.NoError(t, err, "guard should silently ignore read errors") +func TestCheckModifyStateGuard_MissingState(t *testing.T) { + err := modify.CheckStateGuard(t.TempDir()) + assert.NoError(t, err) } func TestCheckModifyStateGuard_UnknownPhase(t *testing.T) { @@ -839,7 +836,7 @@ func TestCheckModifyStateGuard_UnknownPhase(t *testing.T) { require.NoError(t, modify.SaveState(gitDir, state)) err := modify.CheckStateGuard(gitDir) - assert.NoError(t, err, "guard only blocks on 'applying' phase") + assert.ErrorContains(t, err, "unrecognized modify state phase") } // --------------------------------------------------------------------------- @@ -888,11 +885,12 @@ func TestRunModifyAbort_ConflictPhase_Unwinds(t *testing.T) { var rebaseAborted bool var resetCalls []struct{ branch, sha string } current := "" + inProgress := true mock := &git.MockOps{ GitDirFn: func() (string, error) { return tmpDir, nil }, - IsRebaseInProgressFn: func() (bool, error) { return true, nil }, + IsRebaseInProgressFn: func() (bool, error) { return inProgress, nil }, IsCherryPickInProgressFn: func() (bool, error) { return false, nil }, - RebaseAbortFn: func() error { rebaseAborted = true; return nil }, + RebaseAbortFn: func() error { rebaseAborted = true; inProgress = false; return nil }, BranchExistsFn: func(string) (bool, error) { return true, nil }, CheckoutBranchFn: func(name string) error { current = name; return nil }, ResetHardFn: func(sha string) error { @@ -970,3 +968,276 @@ func TestRunModifyAbort_PendingSubmit_NoUnwind(t *testing.T) { assert.True(t, modify.StateExists(tmpDir), "pending-submit state should be preserved") assert.Contains(t, output, "gh stack submit") } + +func TestCheckModifyPreconditions_Worktrees(t *testing.T) { + for _, ownerBranch := range []string{"", "main", "b2"} { + t.Run("foreign owner "+ownerBranch, func(t *testing.T) { + dir, origin, foreign := t.TempDir(), t.TempDir(), t.TempDir() + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, {Branch: "b2"}, + }, + } + writeStackFile(t, dir, s) + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return dir, nil }, + RootDirFn: func() (string, error) { return origin, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + IsAncestorFn: func(string, string) (bool, error) { return true, nil }, + WorktreesFn: func() ([]git.Worktree, error) { + return []git.Worktree{ + {Path: origin, Branch: "b1"}, + {Path: foreign, Branch: ownerBranch}, + }, nil + }, + } + restore := git.SetOps(mock) + defer restore() + cfg, _, errR := config.NewTestConfig() + cfg.ForceInteractive = true + var prQueries atomic.Int32 + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { + prQueries.Add(1) + return nil, nil + }, + } + _, err := checkModifyPreconditions(cfg) + cfg.Out.Close() + cfg.Err.Close() + output, readErr := io.ReadAll(errR) + require.NoError(t, readErr) + if ownerBranch == "b2" { + require.Error(t, err) + assert.Contains(t, string(output), "distributed modify is not supported yet") + assert.Contains(t, string(output), foreign) + assert.Zero(t, prQueries.Load(), "distributed guard must run before PR refresh or TUI") + } else { + require.NoError(t, err) + } + assert.False(t, modify.StateExists(dir)) + }) + } +} + +func TestRunModifyRecovery_UsesRecordedOrigin(t *testing.T) { + for _, tc := range []struct{ command, conflictType string }{ + {"continue", "rebase"}, {"abort", "rebase"}, + {"continue", "cherry_pick"}, {"abort", "cherry_pick"}, + } { + t.Run(tc.command+" "+tc.conflictType, func(t *testing.T) { + common, origin, caller := t.TempDir(), t.TempDir(), t.TempDir() + originDir, callerDir := filepath.Join(common, "worktrees", "origin"), filepath.Join(common, "worktrees", "caller") + require.NoError(t, os.MkdirAll(originDir, 0755)) + require.NoError(t, os.MkdirAll(callerDir, 0755)) + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}}} + if tc.conflictType == "cherry_pick" { + s.Branches = append(s.Branches, stack.BranchRef{Branch: "B"}) + } + writeStackFile(t, common, s) + metadata, err := json.Marshal(s) + require.NoError(t, err) + state := &modify.StateFile{ + SchemaVersion: 1, Phase: modify.PhaseConflict, ConflictBranch: "A", ConflictType: tc.conflictType, + OriginalBranch: "A", + Snapshot: modify.Snapshot{ + StackMetadata: metadata, + Branches: []modify.BranchSnapshot{{Name: "A", TipSHA: "original"}}, + }, + Worktrees: &worktree.Context{ + Origin: worktree.Location{Path: origin, ID: filepath.Join("worktrees", "origin")}, + Pending: "A", + PendingBefore: "original", + }, + } + if tc.conflictType == "cherry_pick" { + state.ConflictBranch, state.FoldBranch, state.FoldTarget = "B", "B", "A" + state.Snapshot.Branches = append(state.Snapshot.Branches, modify.BranchSnapshot{Name: "B", TipSHA: "source"}) + } + state.RecordStack(&s) + require.NoError(t, modify.SaveState(common, state)) + inProgress, continued, aborted := true, false, false + sha := "original" + originOps := &git.MockOps{ + GitDirFn: func() (string, error) { return originDir, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return origin, nil }, + CurrentBranchFn: func() (string, error) { return "A", nil }, + RevParseFn: func(string) (string, error) { return sha, nil }, + IsRebaseInProgressFn: func() (bool, error) { return inProgress && tc.conflictType == "rebase", nil }, + RebaseContinueFn: func(git.RebaseOpts) error { + require.Equal(t, "rebase", tc.conflictType) + continued, inProgress, sha = true, false, "updated" + return nil + }, + RebaseAbortFn: func() error { + require.Equal(t, "rebase", tc.conflictType) + aborted, inProgress = true, false + return nil + }, + IsCherryPickInProgressFn: func() (bool, error) { return inProgress && tc.conflictType == "cherry_pick", nil }, + CherryPickContinueFn: func() error { + require.Equal(t, "cherry_pick", tc.conflictType) + continued, inProgress, sha = true, false, "updated" + return nil + }, + CherryPickAbortFn: func() error { + require.Equal(t, "cherry_pick", tc.conflictType) + aborted, inProgress = true, false + return nil + }, + } + callerSensitiveCalls := 0 + callerOps := &git.MockOps{ + GitDirFn: func() (string, error) { return callerDir, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return caller, nil }, + CurrentBranchFn: func() (string, error) { return "observer", nil }, + RevParseFn: func(string) (string, error) { return sha, nil }, + CheckoutBranchFn: func(string) error { + callerSensitiveCalls++ + return nil + }, + IsRebaseInProgressFn: func() (bool, error) { callerSensitiveCalls++; return false, nil }, + IsCherryPickInProgressFn: func() (bool, error) { + callerSensitiveCalls++ + return false, nil + }, + HasUncommittedChangesFn: func() (bool, error) { + callerSensitiveCalls++ + return true, nil + }, + WorktreesFn: func() ([]git.Worktree, error) { + return []git.Worktree{{Path: origin, Branch: "A"}, {Path: caller, Branch: "observer"}}, nil + }, + } + callerOps.ForWorktreeFn = func(path string) (git.Ops, error) { + require.True(t, worktree.SamePath(path, origin)) + return originOps, nil + } + restore := git.SetOps(callerOps) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + if tc.command == "continue" { + require.NoError(t, runModifyContinue(cfg)) + assert.True(t, continued) + } else { + require.NoError(t, runModifyAbort(cfg)) + assert.True(t, aborted) + } + assert.Zero(t, callerSensitiveCalls) + assert.False(t, modify.StateExists(common)) + assert.Nil(t, cfg.StackMutation) + }) + } +} + +func TestModify_InvalidJournalsAreRetained(t *testing.T) { + for _, content := range []string{"not json", `{"schema_version":1,"phase":"unknown"}`} { + t.Run(content, func(t *testing.T) { + dir := t.TempDir() + path := modify.StatePath(dir) + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return dir, nil }}) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.Error(t, runModifyAbort(cfg)) + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, content, string(got)) + require.Error(t, checkNoModifyInProgress(cfg, dir)) + }) + } +} + +func TestModifyStateIOFailures(t *testing.T) { + dir := t.TempDir() + path := modify.StatePath(dir) + require.NoError(t, os.Mkdir(path, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(path, "keep"), []byte("keep"), 0644)) + require.Error(t, modify.SaveState(dir, &modify.StateFile{SchemaVersion: 1, Phase: modify.PhaseApplying})) + require.Error(t, modify.ClearState(dir)) + _, err := os.Stat(filepath.Join(path, "keep")) + require.NoError(t, err) + require.Error(t, modify.CheckStateGuard(dir)) +} + +func TestRunModifyContinue_LegacyPrivateJournalKeepsOriginalCatalog(t *testing.T) { + common, origin := t.TempDir(), t.TempDir() + private := filepath.Join(common, "worktrees", "legacy") + require.NoError(t, os.MkdirAll(private, 0755)) + other := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "other"}}} + s := stack.Stack{Trunk: other.Trunk, Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}, {Branch: "C"}}} + writeStackFile(t, common, other) + writeStackFile(t, private, s) + metadata, err := json.Marshal(s) + require.NoError(t, err) + state := &modify.StateFile{ + SchemaVersion: 1, Phase: modify.PhaseConflict, ConflictType: "rebase", ConflictBranch: "A", + OriginalBranch: "A", RemainingBranches: []string{"B", "C"}, + OriginalRefs: map[string]string{"A": "sha-main", "B": "sha-A", "C": "sha-B"}, + Snapshot: modify.Snapshot{ + StackMetadata: metadata, + Branches: []modify.BranchSnapshot{ + {Name: "A", TipSHA: "sha-A"}, {Name: "B", TipSHA: "sha-B"}, {Name: "C", TipSHA: "sha-C"}, + }, + }, + } + require.NoError(t, modify.SaveState(private, state)) + inProgress, refused := true, false + continued := 0 + current := "A" + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return private, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return origin, nil }, + CurrentBranchFn: func() (string, error) { return current, nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + IsAncestorFn: func(string, string) (bool, error) { return false, nil }, + IsRebaseInProgressFn: func() (bool, error) { return inProgress, nil }, + RebaseContinueFn: func(git.RebaseOpts) error { + continued++ + inProgress = false + return nil + }, + RebaseOntoFn: func(_, _, branch string, _ git.RebaseOpts) error { + current = branch + if branch == "B" && !refused { + refused, inProgress = true, true + return assert.AnError + } + return nil + }, + CheckoutBranchFn: func(branch string) error { current = branch; return nil }, + }) + defer restore() + cfg, _, errR := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.ErrorIs(t, runModifyContinue(cfg), ErrConflict) + saved, err := modify.LoadState(private) + require.NoError(t, err) + require.NotNil(t, saved.Worktrees, "continuation should record its origin before further changes") + continueErr := runModifyContinue(cfg) + cfg.Out.Close() + cfg.Err.Close() + stderr, err := io.ReadAll(errR) + require.NoError(t, err) + require.NoError(t, continueErr, "%s", stderr) + assert.Equal(t, 2, continued) + assert.False(t, modify.StateExists(private)) + commonCatalog, err := stack.Load(common) + require.NoError(t, err) + require.Len(t, commonCatalog.Stacks, 1) + assert.Equal(t, []string{"other"}, commonCatalog.Stacks[0].BranchNames()) + privateCatalog, err := stack.Load(private) + require.NoError(t, err) + assert.Equal(t, []string{"A", "B", "C"}, privateCatalog.Stacks[0].BranchNames()) +} diff --git a/cmd/navigate.go b/cmd/navigate.go index fe0fe53..182fce4 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -4,12 +4,12 @@ import ( "strconv" "github.com/github/gh-stack/internal/config" - "github.com/github/gh-stack/internal/git" "github.com/spf13/cobra" ) func UpCmd(cfg *config.Config) *cobra.Command { - return &cobra.Command{ + var printPath bool + cmd := &cobra.Command{ Use: "up [n]", Short: "Check out a branch further up in the stack (further from the trunk)", Long: `Check out a branch further up in the stack (further from the trunk). @@ -30,13 +30,16 @@ Merged branches are automatically skipped.`, return ErrInvalidArgs } } - return runNavigate(cfg, n) + return runNavigateWithPath(cfg, n, printPath) }, } + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the target worktree path without switching a branch held elsewhere") + return cmd } func DownCmd(cfg *config.Config) *cobra.Command { - return &cobra.Command{ + var printPath bool + cmd := &cobra.Command{ Use: "down [n]", Short: "Check out a branch further down in the stack (closer to the trunk)", Long: `Check out a branch further down in the stack (closer to the trunk). @@ -57,43 +60,60 @@ Merged branches are automatically skipped.`, return ErrInvalidArgs } } - return runNavigate(cfg, -n) + return runNavigateWithPath(cfg, -n, printPath) }, } + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the target worktree path without switching a branch held elsewhere") + return cmd } func TopCmd(cfg *config.Config) *cobra.Command { - return &cobra.Command{ + var printPath bool + cmd := &cobra.Command{ Use: "top", Short: "Check out the top branch of the stack (furthest from the trunk)", Long: `Check out the top branch of the stack (furthest from the trunk). Merged branches are automatically skipped.`, Example: ` # Jump to the top of the stack $ gh stack top`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runNavigateToEnd(cfg, true) + return runNavigateToEndWithPath(cfg, true, printPath) }, } + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the target worktree path without switching a branch held elsewhere") + return cmd } func BottomCmd(cfg *config.Config) *cobra.Command { - return &cobra.Command{ + var printPath bool + cmd := &cobra.Command{ Use: "bottom", Short: "Check out the bottom branch of the stack (closest to the trunk)", Long: `Check out the bottom branch of the stack (closest to the trunk). Merged branches are automatically skipped.`, Example: ` # Jump to the bottom of the stack $ gh stack bottom`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runNavigateToEnd(cfg, false) + return runNavigateToEndWithPath(cfg, false, printPath) }, } + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the target worktree path without switching a branch held elsewhere") + return cmd } func runNavigate(cfg *config.Config, delta int) error { - result, err := loadStack(cfg, "") + return runNavigateWithPath(cfg, delta, false) +} + +func runNavigateWithPath(cfg *config.Config, delta int, printPath bool) error { + if printPath { + cfg = noninteractiveConfig(cfg) + } + result, err := loadNavigationStack(cfg, printPath) if err != nil { - return ErrNotInStack + return stackLookupError(err) } s := result.Stack currentBranch := result.CurrentBranch @@ -109,13 +129,19 @@ func runNavigate(cfg *config.Config, delta int) error { cfg.Warningf("Warning: all branches in this stack have been merged") } target := s.Branches[targetIdx].Branch - if err := git.CheckoutBranch(target); err != nil { + if err := checkoutWorktreeBranch(cfg, target, printPath); err != nil { return err } + if printPath { + return nil + } cfg.Successf("Switched to %s", target) return nil } cfg.Printf("Already at the bottom of the stack") + if printPath { + return checkoutWorktreeBranch(cfg, currentBranch, true) + } return nil } @@ -181,13 +207,19 @@ func runNavigate(cfg *config.Config, delta int) error { } else { cfg.Printf("Already at the bottom of the stack") } + if printPath { + return checkoutWorktreeBranch(cfg, currentBranch, true) + } return nil } target := s.Branches[newIdx].Branch - if err := git.CheckoutBranch(target); err != nil { + if err := checkoutWorktreeBranch(cfg, target, printPath); err != nil { return err } + if printPath { + return nil + } if skipped > 0 { cfg.Printf("Skipped %d merged %s", skipped, plural(skipped, "branch", "branches")) @@ -205,9 +237,16 @@ func runNavigate(cfg *config.Config, delta int) error { } func runNavigateToEnd(cfg *config.Config, top bool) error { - result, err := loadStack(cfg, "") + return runNavigateToEndWithPath(cfg, top, false) +} + +func runNavigateToEndWithPath(cfg *config.Config, top, printPath bool) error { + if printPath { + cfg = noninteractiveConfig(cfg) + } + result, err := loadNavigationStack(cfg, printPath) if err != nil { - return ErrNotInStack + return stackLookupError(err) } s := result.Stack currentBranch := result.CurrentBranch @@ -236,12 +275,18 @@ func runNavigateToEnd(cfg *config.Config, top bool) error { } else { cfg.Printf("Already at the bottom of the stack") } + if printPath { + return checkoutWorktreeBranch(cfg, target, true) + } return nil } - if err := git.CheckoutBranch(target); err != nil { + if err := checkoutWorktreeBranch(cfg, target, printPath); err != nil { return err } + if printPath { + return nil + } if s.Branches[targetIdx].IsMerged() { cfg.Warningf("Warning: you are on merged branch %q", target) diff --git a/cmd/navigate_test.go b/cmd/navigate_test.go index 90feefb..0e575c8 100644 --- a/cmd/navigate_test.go +++ b/cmd/navigate_test.go @@ -5,15 +5,119 @@ import ( "io" "os" "path/filepath" + "strconv" "testing" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestNavigation_PrintPath(t *testing.T) { + commands := []struct { + name, current, target string + command func(*config.Config) *cobra.Command + args []string + }{ + {"up", "b1", "b2", UpCmd, nil}, + {"down", "b2", "b1", DownCmd, nil}, + {"top", "b1", "b2", TopCmd, nil}, + {"bottom", "b2", "b1", BottomCmd, nil}, + {"trunk", "b1", "main", TrunkCmd, nil}, + {"checkout", "b1", "b2", CheckoutCmd, []string{"b2"}}, + {"already top", "b2", "b2", TopCmd, nil}, + {"already bottom", "b1", "b1", BottomCmd, nil}, + {"already trunk", "main", "main", TrunkCmd, nil}, + {"clamped up", "b2", "b2", UpCmd, nil}, + {"clamped down", "b1", "b1", DownCmd, nil}, + } + for _, tt := range commands { + t.Run(tt.name, func(t *testing.T) { + for _, foreign := range []bool{false, true} { + if foreign && tt.current == tt.target { + continue + } + t.Run(strconv.FormatBool(foreign), func(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "other tree") + writeStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + var checkouts []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + CurrentBranchFn: func() (string, error) { return tt.current, nil }, + BranchExistsFn: func(string) (bool, error) { return true, nil }, + WorktreesFn: func() ([]git.Worktree, error) { + if foreign { + return []git.Worktree{{Path: owner, Branch: tt.target}}, nil + } + return nil, nil + }, + CheckoutBranchFn: func(branch string) error { + checkouts = append(checkouts, branch) + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.SelectFn = func(string, string, []string) (int, error) { + t.Fatal("path mode must not prompt") + return 0, nil + } + cmd := tt.command(cfg) + cmd.SetArgs(append(append([]string{}, tt.args...), "--print-path")) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + require.NoError(t, cmd.Execute()) + out, _ := commandOutput(t, cfg, outR, errR) + want := root + if foreign { + want = owner + } + assert.Equal(t, want+"\n", out) + assert.True(t, cfg.ForceInteractive) + assert.False(t, cfg.NonInteractive, "the caller's config must not be mutated") + if foreign || tt.current == tt.target { + assert.Empty(t, checkouts) + } else { + assert.Equal(t, []string{tt.target}, checkouts) + } + }) + } + }) + } +} + +func TestNavigation_PrintPathRejectsAmbiguity(t *testing.T) { + for i, constructor := range []func(*config.Config) *cobra.Command{UpCmd, DownCmd, TopCmd, BottomCmd, TrunkCmd} { + t.Run(strconv.Itoa(i), func(t *testing.T) { + common := t.TempDir() + writeStackFileMulti(t, common, + stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "one"}}}, + stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "two"}}}, + ) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cmd := constructor(cfg) + cmd.SetArgs([]string{"--print-path"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.ErrorIs(t, err, ErrDisambiguate, cmd.Name()) + assert.Empty(t, out) + assert.Contains(t, diagnostics, "multiple stacks") + }) + } +} + // readCfgOutput closes cfg writers and reads all captured output. func readCfgOutput(cfg *config.Config, outR, errR *os.File) string { cfg.Out.Close() diff --git a/cmd/push.go b/cmd/push.go index 34bee03..9455a30 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -5,7 +5,6 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" - "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -42,15 +41,14 @@ Merged and queued branches are automatically skipped.`, } func runPush(cfg *config.Config, opts *pushOptions) error { - gitDir, err := git.GitDir() + release, err := beginStackMutation(cfg, "push") if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err } - - if err := modify.CheckStateGuard(gitDir); err != nil { - cfg.Errorf("%s", err) - return ErrModifyRecovery + defer release() + gitDir, err := stackStateDir(cfg) + if err != nil { + return err } sf, err := stack.Load(gitDir) @@ -116,7 +114,7 @@ func runPush(cfg *config.Config, opts *pushOptions) error { updateBaseSHAs(s) if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) + return stackSaveError(cfg, err) } cfg.Successf("Pushed %d branches", len(activeBranches)) diff --git a/cmd/rebase.go b/cmd/rebase.go index bd55294..62cedd7 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -6,12 +6,14 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" "github.com/spf13/cobra" ) @@ -27,6 +29,13 @@ type rebaseOptions struct { } type rebaseState struct { + ExecutionMode string `json:"executionMode,omitempty"` + Phase string `json:"phase,omitempty"` + Worktrees *worktree.Context `json:"worktrees,omitempty"` + StackID string `json:"stackId,omitempty"` + StackTrunk string `json:"stackTrunk,omitempty"` + StackBranches []string `json:"stackBranches,omitempty"` + OriginalStack *stack.Stack `json:"originalStack,omitempty"` CurrentBranchIndex int `json:"currentBranchIndex"` ConflictBranch string `json:"conflictBranch"` RemainingBranches []string `json:"remainingBranches"` @@ -42,7 +51,10 @@ type rebaseState struct { EndIndex int `json:"endIndex,omitempty"` } -const rebaseStateFile = "gh-stack-rebase-state" +const ( + rebaseStateFile = "gh-stack-rebase-state" + originOnlyRebaseMode = "origin-only" +) func RebaseCmd(cfg *config.Config) *cobra.Command { opts := &rebaseOptions{} @@ -57,7 +69,12 @@ layer in its commit history, rebasing if necessary. Use --no-trunk to skip fetching and rebasing with the trunk branch. Only the inter-branch rebases are performed (branch 2 onto branch 1, -branch 3 onto branch 2, etc.).`, +branch 3 onto branch 2, etc.). + +All stack branches and any trunk being updated must currently be unoccupied +or checked out in this worktree. Cross-worktree rewrites are refused before +requested mutations, after shared-catalog migration. Continue and abort must +be run in the worktree where the rebase started.`, Example: ` # Rebase the entire stack $ gh stack rebase @@ -97,10 +114,20 @@ branch 3 onto branch 2, etc.).`, } func runRebase(cfg *config.Config, opts *rebaseOptions) error { - gitDir, err := git.GitDir() + kind := "rebase" + if opts.cont { + kind = "rebase-continue" + } else if opts.abort { + kind = "rebase-abort" + } + release, err := beginStackMutation(cfg, kind) if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err + } + defer release() + gitDir, err := stackStateDir(cfg) + if err != nil { + return err } if opts.cont { @@ -118,28 +145,54 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { result, err := loadStack(cfg, opts.branch) if err != nil { - return ErrNotInStack + return stackLookupError(err) } sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch - - // Enable git rerere so conflict resolutions are remembered. - if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + originalTrunk := s.Trunk.Branch + if err := requireLocalBranches(cfg, s.BranchNames()); err != nil { + return err + } + ctx, err := worktree.New() + if err != nil { + cfg.Errorf("%s", err) return ErrSilent } - - var trunk trunkTarget + anchor := currentBranch + if opts.branch != "" { + anchor = opts.branch + } + var remote string if !opts.noTrunk { - // Resolve remote for fetch and trunk comparison - remote, err := pickRemote(cfg, currentBranch, opts.remote) + remote, err = pickRemote(cfg, anchor, opts.remote) if err != nil { if !errors.Is(err, errInterrupt) { cfg.Errorf("%s", err) } return ErrSilent } + trunkBranch, err := normalizeTrunkBranch(s.Trunk.Branch, remote) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if err := requireLocalBranches(cfg, []string{trunkBranch}); err != nil { + return err + } + } + if err := worktree.CheckClean(git.CurrentOps(), ctx.Origin.Path); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + + // Enable git rerere so conflict resolutions are remembered. + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return ErrSilent + } + var trunk trunkTarget + if !opts.noTrunk { trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch) if err != nil { return err @@ -155,10 +208,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { cfg.Printf("Stack detected: %s", s.DisplayChain()) - currentIdx := s.IndexOf(currentBranch) + currentIdx := s.IndexOf(anchor) if currentIdx < 0 { currentIdx = 0 } + if len(s.Branches) == 0 { + cfg.Printf("No branches to rebase") + return nil + } if opts.upstack && currentIdx >= 0 && s.Branches[currentIdx].IsMerged() { cfg.Warningf("Current branch %q has already been merged", currentBranch) @@ -212,6 +269,19 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { } } + state := newWorktreeRebaseState(s, ctx, currentBranch, originalRefs, trunk, startIdx, endIdx) + state.CommitterDateIsAuthorDate = opts.committerDateIsAuthorDate + state.NoTrunk = opts.noTrunk + state.UseOnto, state.OntoOldBase = needsOnto, ontoOldBase + if s.Trunk.Branch != originalTrunk { + if err := stack.Save(gitDir, sf); err != nil { + return handleSaveError(cfg, err) + } + } + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } rebaseResult := cascadeRebase(cascadeRebaseOpts{ Cfg: cfg, Stack: s, @@ -226,12 +296,8 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if rebaseResult.Err != nil { cfg.Errorf("%v", rebaseResult.Err) - if rebaseResult.Rebased { - if err := restoreRebaseRefs(cfg, currentBranch, originalRefs); err != nil { - return err - } - } else { - _ = git.CheckoutBranch(currentBranch) + if err := abortRebase(cfg, gitDir); err != nil { + return err } return ErrSilent } @@ -239,23 +305,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { if rebaseResult.Conflicted { cfg.Warningf("Rebasing %s onto %s — conflict", rebaseResult.ConflictBranch, rebaseResult.ConflictBase) - state := &rebaseState{ - CurrentBranchIndex: rebaseResult.ConflictIdx, - ConflictBranch: rebaseResult.ConflictBranch, - RemainingBranches: rebaseResult.Remaining, - OriginalBranch: currentBranch, - OriginalRefs: originalRefs, - UseOnto: rebaseResult.NeedsOnto, - OntoOldBase: rebaseResult.OntoOldBase, - CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, - NoTrunk: opts.noTrunk, - TrunkRef: trunk.Ref, - TrunkSHA: trunk.SHA, - StartIndex: startIdx, - EndIndex: endIdx, - } + state.Phase = "conflict" + state.CurrentBranchIndex = rebaseResult.ConflictIdx + state.ConflictBranch = rebaseResult.ConflictBranch + state.RemainingBranches = rebaseResult.Remaining + state.UseOnto, state.OntoOldBase = rebaseResult.NeedsOnto, rebaseResult.OntoOldBase if err := saveRebaseState(gitDir, state); err != nil { - cfg.Warningf("failed to save rebase state: %s", err) + cfg.Errorf("failed to save conflict progress; run `gh stack rebase --abort`: %s", err) + return ErrSilent } printConflictDetails(cfg, rebaseResult.ConflictBase) @@ -265,26 +322,21 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { rebaseResult.ConflictBranch, cfg.ColorCyan("gh stack rebase --continue")) cfg.Printf("Or abort this operation with `%s`", cfg.ColorCyan("gh stack rebase --abort")) + cfg.Printf("Run recovery in the original worktree: %s", ctx.Origin.Path) return ErrConflict } - _ = git.CheckoutBranch(currentBranch) - if unstacked := verifyStacked(s, trunk.Ref, startIdx, endIdx); len(unstacked) > 0 { reportUnstacked(cfg, trunk.Ref, unstacked) - if rebaseResult.Rebased { - if err := restoreRebaseRefs(cfg, currentBranch, originalRefs); err != nil { - return err - } + if err := abortRebase(cfg, gitDir); err != nil { + return err } return ErrSilent } - updateBaseSHAs(s) - - _ = syncStackPRs(cfg, s) - - stack.SaveNonBlocking(gitDir, sf) + if err := finishOriginRebase(cfg, gitDir, state, sf, s); err != nil { + return err + } merged := s.MergedBranches() if len(merged) > 0 { @@ -297,9 +349,9 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { rangeDesc := "All branches in stack" if opts.downstack { - rangeDesc = fmt.Sprintf("All downstack branches up to %s", currentBranch) + rangeDesc = fmt.Sprintf("All downstack branches up to %s", anchor) } else if opts.upstack { - rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch) + rangeDesc = fmt.Sprintf("All upstack branches from %s", anchor) } if opts.noTrunk { @@ -316,7 +368,18 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { func continueRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { - cfg.Errorf("no rebase in progress") + if errors.Is(err, os.ErrNotExist) { + cfg.Errorf("no rebase in progress") + } else { + cfg.Errorf("reading rebase recovery state: %s", err) + } + return ErrSilent + } + if err := requireRebaseOrigin(cfg, gitDir, state); err != nil { + return err + } + if state.Phase != "" && state.Phase != "conflict" && state.Phase != "complete" { + cfg.Errorf("rebase stopped in phase %q; run `gh stack rebase --abort` in the original worktree", state.Phase) return ErrSilent } @@ -328,13 +391,21 @@ func continueRebase(cfg *config.Config, gitDir string) error { // Use the saved original branch to find the stack, since git may be in // a detached HEAD state during an active rebase. - s, err := resolveStack(sf, state.OriginalBranch, cfg) + var s *stack.Stack + if state.Worktrees != nil { + s, err = rebaseStackFromState(sf, state) + } else { + s, err = resolveStack(sf, state.OriginalBranch, cfg) + } if err != nil { return err } if s == nil { return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) } + if state.Phase == "complete" { + return finishOriginRebase(cfg, gitDir, state, sf, s) + } trunkRef := state.TrunkRef if trunkRef == "" { trunkRef = s.Trunk.Branch @@ -423,16 +494,16 @@ func continueRebase(cfg *config.Config, gitDir string) error { if result.Err != nil { cfg.Errorf("%v", result.Err) - if err := restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs); err != nil { + if err := abortRebase(cfg, gitDir); err != nil { return err } - clearRebaseState(gitDir) return ErrSilent } if result.Conflicted { cfg.Warningf("Rebasing %s onto %s — conflict", result.ConflictBranch, result.ConflictBase) + state.Phase = "conflict" state.CurrentBranchIndex = result.ConflictIdx state.ConflictBranch = result.ConflictBranch state.RemainingBranches = result.Remaining @@ -452,8 +523,6 @@ func continueRebase(cfg *config.Config, gitDir string) error { } } - _ = git.CheckoutBranch(state.OriginalBranch) - verifyStart, verifyEnd := state.StartIndex, state.EndIndex if verifyEnd <= verifyStart { verifyStart, verifyEnd = 0, len(s.Branches) @@ -463,19 +532,15 @@ func continueRebase(cfg *config.Config, gitDir string) error { } if unstacked := verifyStacked(s, trunkBase, verifyStart, verifyEnd); len(unstacked) > 0 { reportUnstacked(cfg, trunkRef, unstacked) - if err := restoreRebaseRefs(cfg, state.OriginalBranch, state.OriginalRefs); err != nil { + if err := abortRebase(cfg, gitDir); err != nil { return err } - clearRebaseState(gitDir) return ErrSilent } - clearRebaseState(gitDir) - updateBaseSHAs(s) - - _ = syncStackPRs(cfg, s) - - stack.SaveNonBlocking(gitDir, sf) + if err := finishOriginRebase(cfg, gitDir, state, sf, s); err != nil { + return err + } if state.NoTrunk { cfg.Printf("All branches in stack rebased locally (without trunk)") @@ -493,20 +558,60 @@ func continueRebase(cfg *config.Config, gitDir string) error { func abortRebase(cfg *config.Config, gitDir string) error { state, err := loadRebaseState(gitDir) if err != nil { - cfg.Errorf("no rebase in progress") + if errors.Is(err, os.ErrNotExist) { + cfg.Errorf("no rebase in progress") + } else { + cfg.Errorf("reading rebase recovery state: %s", err) + } return ErrSilent } - + if err := requireRebaseOrigin(cfg, gitDir, state); err != nil { + return err + } + if state.OriginalStack != nil { + sf, err := stack.Load(gitDir) + if err != nil { + return err + } + if _, err := rebaseStackFromState(sf, state); err != nil { + return err + } + } inProgress, err := git.IsRebaseInProgress() if err != nil { return fmt.Errorf("checking rebase state: %w", err) } + for branch := range state.OriginalRefs { + if _, err := git.BranchExists(branch); err != nil { + return fmt.Errorf("checking branch %s before restoring: %w", branch, err) + } + } + state.Phase = "restoring" + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Errorf("saving recovery state before restoration: %s", err) + return ErrSilent + } if inProgress { - _ = git.RebaseAbort() + if err := git.RebaseAbort(); err != nil { + cfg.Errorf("aborting rebase; recovery state was retained: %s", err) + return ErrSilent + } + } + root, err := git.RootDir() + if err != nil { + cfg.Errorf("finding recovery worktree: %s", err) + return ErrSilent + } + if err := worktree.CheckClean(git.CurrentOps(), root); err != nil { + cfg.Errorf("recovery state was retained: %s", err) + return ErrSilent } var restoreErrors []string for branch, sha := range state.OriginalRefs { + if current, err := git.RevParse(branch); err == nil && current == sha { + continue + } if err := git.CheckoutBranch(branch); err != nil { restoreErrors = append(restoreErrors, fmt.Sprintf("checkout %s: %s", branch, err)) continue @@ -515,22 +620,177 @@ func abortRebase(cfg *config.Config, gitDir string) error { restoreErrors = append(restoreErrors, fmt.Sprintf("reset %s: %s", branch, err)) } } - - _ = git.CheckoutBranch(state.OriginalBranch) - clearRebaseState(gitDir) - + if err := git.CheckoutBranch(state.OriginalBranch); err != nil { + restoreErrors = append(restoreErrors, fmt.Sprintf("restoring original checkout: %s", err)) + } if len(restoreErrors) > 0 { - cfg.Warningf("Rebase aborted but some branches could not be fully restored:") + cfg.Warningf("Some branches could not be fully restored; recovery state was retained:") for _, e := range restoreErrors { cfg.Printf(" %s", e) } return ErrSilent } + if state.OriginalStack != nil { + if err := restoreWorktreeRebaseMetadata(gitDir, state); err != nil { + cfg.Errorf("restoring metadata; recovery state was retained: %s", err) + return ErrSilent + } + } + if err := clearRebaseState(gitDir); err != nil { + cfg.Errorf("branches restored, but recovery state could not be cleared: %s", err) + return ErrSilent + } cfg.Successf("Rebase aborted and branches restored") return nil } +func newWorktreeRebaseState(s *stack.Stack, ctx *worktree.Context, originalBranch string, refs map[string]string, trunk trunkTarget, start, end int) *rebaseState { + snapshot := *s + snapshot.Branches = append([]stack.BranchRef{}, s.Branches...) + return &rebaseState{ + ExecutionMode: originOnlyRebaseMode, + Phase: "applying", + Worktrees: ctx, + StackID: s.ID, + StackTrunk: s.Trunk.Branch, + StackBranches: s.BranchNames(), + OriginalStack: &snapshot, + OriginalBranch: originalBranch, + OriginalRefs: refs, + CurrentBranchIndex: start, + StartIndex: start, + EndIndex: end, + TrunkRef: trunk.Ref, + TrunkSHA: trunk.SHA, + } +} + +func requireRebaseOrigin(cfg *config.Config, dir string, state *rebaseState) error { + localDir, err := git.GitDir() + if err != nil { + return err + } + if state.Worktrees == nil { + if !worktree.SamePath(localDir, dir) { + cfg.Errorf("legacy rebase recovery must run in its original worktree (Git directory %s)", dir) + return ErrRebaseActive + } + } else { + ops, err := state.Worktrees.OriginOps() + if err != nil { + cfg.Errorf("%s", err) + return ErrRebaseActive + } + originDir, err := ops.GitDir() + if err != nil { + return err + } + if !worktree.SamePath(localDir, originDir) { + cfg.Errorf("rebase recovery must run in its original worktree: %s", state.Worktrees.Origin.Path) + cfg.Printf("Return there before running `gh stack rebase --continue` or `gh stack rebase --abort`") + return ErrRebaseActive + } + } + branches := append([]string{state.OriginalBranch, state.ConflictBranch}, state.RemainingBranches...) + for name := range state.OriginalRefs { + branches = append(branches, name) + } + if err := requireLocalBranches(cfg, branches); err != nil { + return err + } + if state.Phase == "complete" { + root, err := git.RootDir() + if err != nil { + return err + } + if err := worktree.CheckClean(git.CurrentOps(), root); err != nil { + cfg.Errorf("recovery state was retained: %s", err) + return ErrSilent + } + } + return nil +} + +func rebaseStackFromState(sf *stack.StackFile, state *rebaseState) (*stack.Stack, error) { + switch state.Phase { + case "applying", "conflict", "complete", "restoring": + default: + return nil, fmt.Errorf("unknown saved rebase phase %q", state.Phase) + } + var target *stack.Stack + for i := range sf.Stacks { + s := &sf.Stacks[i] + if s.Trunk.Branch != state.StackTrunk || !slices.Equal(s.BranchNames(), state.StackBranches) { + continue + } + if (state.Phase == "applying" || state.Phase == "conflict") && state.StackID != "" && s.ID != "" && state.StackID != s.ID { + continue + } + if target != nil { + return nil, fmt.Errorf("saved rebase matches multiple stacks; resolve the catalog before continuing") + } + target = s + } + if target == nil { + return nil, fmt.Errorf("the stack changed since this rebase started; restore its original membership or run gh stack rebase --abort") + } + if state.StartIndex < 0 || state.EndIndex > len(target.Branches) || + state.StartIndex > state.EndIndex || state.CurrentBranchIndex < state.StartIndex || + state.CurrentBranchIndex > state.EndIndex { + return nil, fmt.Errorf("invalid branch range in saved rebase state") + } + return target, nil +} + +func restoreWorktreeRebaseMetadata(dir string, state *rebaseState) error { + sf, err := stack.Load(dir) + if err != nil { + return err + } + target, err := rebaseStackFromState(sf, state) + if err != nil { + return err + } + if !slices.Equal(state.OriginalStack.BranchNames(), target.BranchNames()) { + return fmt.Errorf("original stack snapshot does not match the recovery target") + } + target.Trunk.Head = state.OriginalStack.Trunk.Head + for i, before := range state.OriginalStack.Branches { + target.Branches[i].Base = before.Base + target.Branches[i].Head = before.Head + if sha, err := git.RevParse(before.Branch); err == nil { + target.Branches[i].Head = sha + } else if !before.IsMerged() { + return fmt.Errorf("reading restored branch %s: %w", before.Branch, err) + } + } + return stack.Save(dir, sf) +} + +func finishOriginRebase(cfg *config.Config, dir string, state *rebaseState, sf *stack.StackFile, s *stack.Stack) error { + state.Phase = "complete" + state.RemainingBranches = nil + if err := saveRebaseState(dir, state); err != nil { + cfg.Errorf("saving completed rebase; recovery state was retained: %s", err) + return ErrSilent + } + if err := git.CheckoutBranch(state.OriginalBranch); err != nil { + cfg.Errorf("restoring original checkout; recovery state was retained: %s", err) + return ErrSilent + } + updateBaseSHAsWithTrunk(s, state.TrunkSHA) + _ = syncStackPRs(cfg, s) + if err := stack.Save(dir, sf); err != nil { + return handleSaveError(cfg, err) + } + if err := clearRebaseState(dir); err != nil { + cfg.Errorf("rebase completed but recovery state could not be cleared: %s", err) + return ErrSilent + } + return nil +} + func saveRebaseState(gitDir string, state *rebaseState) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { @@ -551,11 +811,31 @@ func loadRebaseState(gitDir string) (*rebaseState, error) { if err := json.Unmarshal(data, &state); err != nil { return nil, err } + origin := "its original worktree" + if state.Worktrees != nil && state.Worktrees.Origin.Path != "" { + origin = fmt.Sprintf("worktree %q", state.Worktrees.Origin.Path) + } + switch state.ExecutionMode { + case originOnlyRebaseMode: + if state.Worktrees == nil { + return nil, fmt.Errorf("origin-only rebase journal has no recorded worktree; recovery state was retained") + } + case "": + if state.Worktrees != nil { + return nil, fmt.Errorf("this rebase journal uses a different execution lifecycle; use the matching gh-stack build in %s to continue or abort", origin) + } + default: + return nil, fmt.Errorf("unsupported rebase execution mode %q; use the matching gh-stack build in %s to continue or abort", state.ExecutionMode, origin) + } return &state, nil } -func clearRebaseState(gitDir string) { - _ = os.Remove(filepath.Join(gitDir, rebaseStateFile)) +func clearRebaseState(gitDir string) error { + err := os.Remove(filepath.Join(gitDir, rebaseStateFile)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err } func printConflictDetails(cfg *config.Config, branch string) { @@ -563,12 +843,20 @@ func printConflictDetails(cfg *config.Config, branch string) { } func printConflictDetailsWithContinue(cfg *config.Config, branch string, continueCmd string) { - files, err := git.ConflictedFiles() + printConflictDetailsAt(cfg, git.CurrentOps(), "", branch, continueCmd) +} + +func printConflictDetailsAt(cfg *config.Config, ops git.Ops, path, branch, continueCmd string) { + if path != "" { + cfg.Printf("Conflict worktree: %s", path) + cfg.Printf("Resolve and stage files in that worktree; continuation may be run from any worktree.") + } + files, err := ops.ConflictedFiles() if err == nil && len(files) > 0 { cfg.Printf("") cfg.Printf("%s", cfg.ColorBold("Conflicted files:")) for _, f := range files { - info, err := git.FindConflictMarkers(f) + info, err := ops.FindConflictMarkers(f) if err != nil || len(info.Sections) == 0 { cfg.Printf(" %s %s", cfg.ColorWarning("C"), f) continue diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 9dd9126..15a3e9c 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -12,11 +12,13 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1338,14 +1340,16 @@ func TestRebase_Abort_WithActiveRebase(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) var rebaseAbortCalled bool + inProgress := true var resets []resetCall var checkouts []string currentBranch := "b2" mock := newRebaseMock(tmpDir, currentBranch) - mock.IsRebaseInProgressFn = func() (bool, error) { return true, nil } + mock.IsRebaseInProgressFn = func() (bool, error) { return inProgress, nil } mock.RebaseAbortFn = func() error { rebaseAbortCalled = true + inProgress = false return nil } mock.CheckoutBranchFn = func(name string) error { @@ -2392,3 +2396,505 @@ func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) { assert.Equal(t, []string{"imported two", "imported one", "parent commit"}, subjects) require.NoError(t, issue250GitMayFail(t, cloneDir, "merge-base", "--is-ancestor", "parent", "imported")) } + +func mockForeignOwner(t *testing.T, mock *git.MockOps, common, current, branch string) string { + t.Helper() + root, owner := t.TempDir(), t.TempDir() + mock.RootDirFn = func() (string, error) { return root, nil } + mock.CommonDirFn = func() (string, error) { return common, nil } + mock.WorktreesFn = func() ([]git.Worktree, error) { + return []git.Worktree{{Path: root, Branch: current}, {Path: owner, Branch: branch}}, nil + } + mock.ForWorktreeFn = func(path string) (git.Ops, error) { + if worktree.SamePath(path, root) { + return mock, nil + } + require.True(t, worktree.SamePath(path, owner)) + return &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return filepath.Join(common, "worktrees", "owner"), nil }, + }, nil + } + return owner +} + +func forbidRewriteMutations(t *testing.T, mock *git.MockOps) { + t.Helper() + mock.CheckoutBranchFn = func(string) error { t.Fatal("unexpected checkout"); return nil } + mock.CreateBranchFn = func(string, string) error { t.Fatal("unexpected branch creation"); return nil } + mock.UpdateBranchRefFn = func(string, string) error { t.Fatal("unexpected ref update"); return nil } + mock.ResetHardFn = func(string) error { t.Fatal("unexpected reset"); return nil } + mock.MergeFFFn = func(string) error { t.Fatal("unexpected fast-forward"); return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { t.Fatal("unexpected rebase"); return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { + t.Fatal("unexpected rebase onto") + return nil + } + mock.PushFn = func(string, []string, bool, bool) error { t.Fatal("unexpected push"); return nil } + mock.DeleteBranchFn = func(string, bool) error { t.Fatal("unexpected branch deletion"); return nil } + mock.DeleteTrackingRefFn = func(string, string) error { t.Fatal("unexpected tracking ref deletion"); return nil } +} + +func TestRebase_ForeignTargetsRefusedBeforeMutation(t *testing.T) { + for _, tc := range []struct { + name, owner, trunk string + opts rebaseOptions + merged bool + }{ + {name: "whole stack", owner: "b1"}, + {name: "fast-forward outside downstack range", owner: "b3", opts: rebaseOptions{downstack: true}}, + {name: "rollback outside upstack range", owner: "b1", opts: rebaseOptions{upstack: true, noTrunk: true}}, + {name: "merged rollback target", owner: "b1", merged: true}, + {name: "foreign trunk", owner: "main"}, + {name: "normalized foreign trunk", owner: "main", trunk: "origin/main"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + trunk := tc.trunk + if trunk == "" { + trunk = "main" + } + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: trunk}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + if tc.merged { + s.Branches[0].PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} + } + writeStackFile(t, dir, s) + before, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + mock := newRebaseMock(dir, "b2") + mock.BranchExistsFn = func(name string) (bool, error) { return name != "origin/main", nil } + owner := mockForeignOwner(t, mock, dir, "b2", tc.owner) + forbidRewriteMutations(t, mock) + restore := git.SetOps(mock) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + + require.ErrorIs(t, runRebase(cfg, &tc.opts), ErrInvalidArgs) + + _, output := commandOutput(t, cfg, outR, errR) + assert.Contains(t, output, owner) + assert.Contains(t, output, "cross-worktree rebase and sync are not supported yet") + after, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) + }) + } +} + +func TestRebase_InteractiveSelectionDoesNotCheckoutBeforeRefusal(t *testing.T) { + dir := t.TempDir() + require.NoError(t, stack.Save(dir, &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + {Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "foreign"}, {Branch: "available"}}}, + {Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "independent"}}}, + }, + })) + mock := newRebaseMock(dir, "main") + mockForeignOwner(t, mock, dir, "main", "foreign") + forbidRewriteMutations(t, mock) + restore := git.SetOps(mock) + defer restore() + cfg := issue250TestConfig(t) + cfg.ForceInteractive = true + cfg.SelectFn = func(string, string, []string) (int, error) { return 0, nil } + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{noTrunk: true}), ErrInvalidArgs) +} + +func TestRebase_MigrationPrecedesOwnershipRefusal(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "legacy") + require.NoError(t, os.MkdirAll(private, 0755)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}}) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "independent"}}}) + mock := newRebaseMock(common, "b2") + mockForeignOwner(t, mock, common, "b2", "b1") + forbidRewriteMutations(t, mock) + restore := git.SetOps(mock) + defer restore() + + require.ErrorIs(t, runRebase(issue250TestConfig(t), &rebaseOptions{}), ErrInvalidArgs) + + sf, err := stack.Load(common) + require.NoError(t, err) + require.Len(t, sf.Stacks, 2) + assert.FileExists(t, filepath.Join(common, "gh-stack.pre-worktree-migration")) + assert.FileExists(t, filepath.Join(private, "gh-stack.pre-worktree-migration")) + assert.NoFileExists(t, filepath.Join(private, "gh-stack")) + assert.NoFileExists(t, filepath.Join(common, rebaseStateFile)) +} + +type worktreeRebaseRepo struct { + amendedParentRepo + parentDir string + childDir string +} + +func setupWorktreeRebaseRepo(t *testing.T, conflict bool) worktreeRebaseRepo { + t.Helper() + repo := setupAmendedParentRepo(t, false) + issue250Git(t, repo.dir, "config", "commit.gpgSign", "false") + issue250Git(t, repo.dir, "config", "core.hooksPath", os.DevNull) + issue250Git(t, repo.dir, "checkout", "main") + parentDir := filepath.Join(t.TempDir(), "parent worktree") + childDir := filepath.Join(t.TempDir(), "child worktree") + issue250Git(t, repo.dir, "worktree", "add", parentDir, "parent") + issue250Git(t, repo.dir, "worktree", "add", childDir, "child") + if conflict { + issue250Git(t, repo.dir, "commit", "--allow-empty", "-m", "advance trunk") + issue250Git(t, repo.dir, "push", "origin", "main") + issue250WriteFile(t, parentDir, "base.txt", "parent change\n") + issue250Git(t, parentDir, "add", "base.txt") + issue250Git(t, parentDir, "commit", "--amend", "--no-edit") + issue250WriteFile(t, childDir, "base.txt", "child change\n") + issue250Git(t, childDir, "add", "base.txt") + issue250Git(t, childDir, "commit", "-m", "child conflict") + } + return worktreeRebaseRepo{repo, parentDir, childDir} +} + +func TestRebase_DistributedRefusalPreservesRefsAndWorktrees(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + issue250WriteFile(t, repo.parentDir, "unfinished.txt", "keep working\n") + beforeRefs := issue250Git(t, repo.dir, "show-ref") + beforeCatalog, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + withIssue250Repo(t, repo.childDir) + + require.ErrorIs(t, runRebase(issue250TestConfig(t), &rebaseOptions{upstack: true, noTrunk: true}), ErrInvalidArgs) + + assert.Equal(t, beforeRefs, issue250Git(t, repo.dir, "show-ref")) + afterCatalog, err := os.ReadFile(filepath.Join(repo.gitDir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, beforeCatalog, afterCatalog) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + assert.Equal(t, "parent", issue250Git(t, repo.parentDir, "branch", "--show-current")) + assert.Equal(t, "child", issue250Git(t, repo.childDir, "branch", "--show-current")) + assert.FileExists(t, filepath.Join(repo.parentDir, "unfinished.txt")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) +} + +func TestRebase_SingleOwnerLinkedWorktreeWithForeignTrunk(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + issue250Git(t, repo.parentDir, "checkout", "--detach") + issue250WriteFile(t, repo.dir, "unfinished.txt", "leave trunk alone\n") + trunkBefore := issue250Git(t, repo.dir, "rev-parse", "main") + withIssue250Repo(t, repo.childDir) + + require.NoError(t, runRebase(issue250TestConfig(t), &rebaseOptions{noTrunk: true})) + + assert.Equal(t, trunkBefore, issue250Git(t, repo.dir, "rev-parse", "main")) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + assert.Equal(t, "child", issue250Git(t, repo.childDir, "branch", "--show-current")) + assert.FileExists(t, filepath.Join(repo.dir, "unfinished.txt")) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) + assert.Error(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", repo.oldParent, "child")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) +} + +func TestRebase_SharedRecoveryRequiresOrigin(t *testing.T) { + for _, action := range []string{"continue", "abort"} { + t.Run(action, func(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + issue250Git(t, repo.parentDir, "checkout", "--detach") + childBefore := issue250Git(t, repo.dir, "rev-parse", "child") + withIssue250Repo(t, repo.childDir) + cfg := issue250TestConfig(t) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{noTrunk: true}), ErrConflict) + state, err := loadRebaseState(repo.gitDir) + require.NoError(t, err) + require.NotNil(t, state.Worktrees) + assert.Equal(t, originOnlyRebaseMode, state.ExecutionMode) + assert.True(t, worktree.SamePath(repo.childDir, state.Worktrees.Origin.Path)) + assert.Equal(t, []string{"parent", "child"}, state.StackBranches) + before, err := os.ReadFile(filepath.Join(repo.gitDir, rebaseStateFile)) + require.NoError(t, err) + beforeRefs := issue250Git(t, repo.dir, "show-ref") + + withIssue250Repo(t, repo.dir) + opts := &rebaseOptions{cont: action == "continue", abort: action == "abort"} + require.ErrorIs(t, runRebase(cfg, opts), ErrRebaseActive) + after, err := os.ReadFile(filepath.Join(repo.gitDir, rebaseStateFile)) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.Equal(t, beforeRefs, issue250Git(t, repo.dir, "show-ref")) + assert.True(t, requireGitState(t, requireWorktree(t, git.CurrentOps(), repo.childDir).IsRebaseInProgress)) + assert.False(t, requireGitState(t, git.IsRebaseInProgress)) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + + if opts.cont { + issue250WriteFile(t, repo.childDir, "base.txt", "resolved\n") + issue250Git(t, repo.childDir, "add", "base.txt") + } + withIssue250Repo(t, repo.childDir) + require.NoError(t, runRebase(cfg, opts)) + assert.False(t, requireGitState(t, git.IsRebaseInProgress)) + assert.Equal(t, "child", issue250Git(t, repo.childDir, "branch", "--show-current")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) + if opts.abort { + assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) + } else { + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) + } + }) + } +} + +func TestRebase_RecoveryFindsMovedOrigin(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + issue250Git(t, repo.parentDir, "checkout", "--detach") + childBefore := issue250Git(t, repo.dir, "rev-parse", "child") + withIssue250Repo(t, repo.childDir) + cfg := issue250TestConfig(t) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{noTrunk: true}), ErrConflict) + + withIssue250Repo(t, repo.dir) + moved := filepath.Join(t.TempDir(), "moved child") + issue250Git(t, repo.dir, "worktree", "move", repo.childDir, moved) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{abort: true}), ErrRebaseActive) + assert.True(t, requireGitState(t, requireWorktree(t, git.CurrentOps(), moved).IsRebaseInProgress)) + + withIssue250Repo(t, moved) + require.NoError(t, runRebase(cfg, &rebaseOptions{abort: true})) + assert.False(t, requireGitState(t, git.IsRebaseInProgress)) + assert.Equal(t, childBefore, issue250Git(t, moved, "rev-parse", "child")) + assert.Equal(t, "child", issue250Git(t, moved, "branch", "--show-current")) + assert.NoFileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) +} + +func TestRebase_RecoveryMatchesIdentityNotOriginalCheckout(t *testing.T) { + dir := t.TempDir() + target := stack.Stack{ID: "42", Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}} + unrelated := stack.Stack{ID: "99", Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "independent", Head: "keep"}}} + require.NoError(t, stack.Save(dir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{unrelated, target}})) + mock := newRebaseMock(dir, "independent") + restore := git.SetOps(mock) + defer restore() + ctx, err := worktree.New() + require.NoError(t, err) + state := newWorktreeRebaseState(&target, ctx, "independent", map[string]string{"b1": "old-b1"}, trunkTarget{Ref: "main", SHA: "sha-main"}, 0, 1) + state.Phase, state.ConflictBranch = "conflict", "b1" + require.NoError(t, saveRebaseState(dir, state)) + + require.NoError(t, runRebase(issue250TestConfig(t), &rebaseOptions{cont: true})) + + sf, err := stack.Load(dir) + require.NoError(t, err) + assert.Equal(t, unrelated, sf.Stacks[0]) + assert.Equal(t, "sha-b1", sf.Stacks[1].Branches[0].Head) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) +} + +func TestRebase_LegacyPrivateRecoveryKeepsOriginalCatalog(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "legacy") + require.NoError(t, os.MkdirAll(private, 0755)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "unrelated"}}}) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1", Head: "old-b1"}}}) + before, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + require.NoError(t, saveRebaseState(private, &rebaseState{ + OriginalBranch: "b1", ConflictBranch: "b1", OriginalRefs: map[string]string{"b1": "old-b1"}, + TrunkRef: "main", TrunkSHA: "sha-main", EndIndex: 1, + })) + mock := newRebaseMock(private, "b1") + mock.CommonDirFn = func() (string, error) { return common, nil } + restore := git.SetOps(mock) + defer restore() + + require.NoError(t, runRebase(issue250TestConfig(t), &rebaseOptions{cont: true})) + + after, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + sf, err := stack.Load(private) + require.NoError(t, err) + assert.Equal(t, "sha-b1", sf.Stacks[0].Branches[0].Head) + assert.FileExists(t, filepath.Join(private, "gh-stack")) + assert.NoFileExists(t, filepath.Join(private, rebaseStateFile)) + assert.NoFileExists(t, filepath.Join(common, "gh-stack-migration")) +} + +func TestRebase_AbortRetainsPartialRestore(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}} + writeStackFile(t, dir, s) + current := "b1" + refs := map[string]string{"b1": "rebased-b1", "b2": "rebased-b2"} + fail := true + mock := newRebaseMock(dir, current) + mock.CurrentBranchFn = func() (string, error) { return current, nil } + mock.RevParseFn = func(ref string) (string, error) { return refs[ref], nil } + mock.CheckoutBranchFn = func(branch string) error { current = branch; return nil } + mock.ResetHardFn = func(sha string) error { + if current == "b2" && fail { + return errors.New("reset failed") + } + refs[current] = sha + return nil + } + restore := git.SetOps(mock) + defer restore() + ctx, err := worktree.New() + require.NoError(t, err) + state := newWorktreeRebaseState(&s, ctx, "b1", map[string]string{"b1": "old-b1", "b2": "old-b2"}, trunkTarget{}, 0, 2) + require.NoError(t, saveRebaseState(dir, state)) + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{abort: true}), ErrSilent) + retained, err := loadRebaseState(dir) + require.NoError(t, err) + assert.Equal(t, "restoring", retained.Phase) + assert.Equal(t, "old-b1", refs["b1"]) + assert.Equal(t, "rebased-b2", refs["b2"]) + + fail = false + require.NoError(t, runRebase(cfg, &rebaseOptions{abort: true})) + assert.Equal(t, "old-b2", refs["b2"]) + assert.Equal(t, "b1", current) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) +} + +func TestRebase_CompletedJournalSurvivesCatalogSaveFailure(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}} + writeStackFile(t, dir, s) + mock := newRebaseMock(dir, "b1") + mock.RebaseContinueFn = func(git.RebaseOpts) error { t.Fatal("must not repeat a completed native rebase"); return nil } + restore := git.SetOps(mock) + defer restore() + ctx, err := worktree.New() + require.NoError(t, err) + state := newWorktreeRebaseState(&s, ctx, "b1", map[string]string{"b1": "old-b1"}, trunkTarget{Ref: "main", SHA: "sha-main"}, 0, 1) + state.Phase, state.ConflictBranch = "conflict", "b1" + require.NoError(t, saveRebaseState(dir, state)) + lock, err := stack.Lock(dir) + require.NoError(t, err) + defer lock.Unlock() + oldTimeout := stack.LockTimeout + stack.LockTimeout = 10 * time.Millisecond + defer func() { stack.LockTimeout = oldTimeout }() + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{cont: true}), ErrLockFailed) + retained, err := loadRebaseState(dir) + require.NoError(t, err) + assert.Equal(t, "complete", retained.Phase) + + lock.Unlock() + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) +} + +func TestRebase_LegacyContinuePersistsCatalogUnderOperationLock(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", Head: "old-b1", Base: "old-base"}}, + }) + require.NoError(t, saveRebaseState(dir, &rebaseState{ + OriginalBranch: "b1", ConflictBranch: "b1", OriginalRefs: map[string]string{"b1": "old-b1"}, + TrunkRef: "main", TrunkSHA: "sha-main", EndIndex: 1, + })) + mock := newRebaseMock(dir, "b1") + mock.RevParseFn = func(ref string) (string, error) { + if ref == "b1" { + return "new-b1", nil + } + return "sha-main", nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { return a == "sha-main" && d == "b1", nil } + restore := git.SetOps(mock) + defer restore() + cfg := issue250TestConfig(t) + + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + + sf, err := stack.Load(dir) + require.NoError(t, err) + assert.Equal(t, "new-b1", sf.Stacks[0].Branches[0].Head) + assert.Equal(t, "sha-main", sf.Stacks[0].Branches[0].Base) + _, err = os.Stat(filepath.Join(dir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRebase_CompletedRecoveryPreservesNewWork(t *testing.T) { + for _, action := range []string{"continue", "abort"} { + for _, busy := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/busy=%t", action, busy), func(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}} + writeStackFile(t, dir, s) + mock := newRebaseMock(dir, "b1") + mock.IsRebaseInProgressFn = func() (bool, error) { return busy, nil } + mock.HasUncommittedChangesFn = func() (bool, error) { return !busy, nil } + mock.RebaseAbortFn = func() error { t.Fatal("must not abort a new Git operation"); return nil } + forbidRewriteMutations(t, mock) + restore := git.SetOps(mock) + defer restore() + ctx, err := worktree.New() + require.NoError(t, err) + state := newWorktreeRebaseState(&s, ctx, "b1", map[string]string{"b1": "old-b1"}, trunkTarget{}, 0, 1) + state.Phase = "complete" + require.NoError(t, saveRebaseState(dir, state)) + before, err := os.ReadFile(filepath.Join(dir, rebaseStateFile)) + require.NoError(t, err) + + require.ErrorIs(t, runRebase(issue250TestConfig(t), &rebaseOptions{cont: action == "continue", abort: action == "abort"}), ErrSilent) + + after, err := os.ReadFile(filepath.Join(dir, rebaseStateFile)) + require.NoError(t, err) + assert.Equal(t, before, after) + }) + } + } +} + +func TestRebase_RecoveryRejectsDifferentExecutionLifecycle(t *testing.T) { + for _, mode := range []string{"", "future-lifecycle", originOnlyRebaseMode} { + for _, action := range []string{"continue", "abort"} { + t.Run(mode+"/"+action, func(t *testing.T) { + dir := t.TempDir() + mock := newRebaseMock(dir, "b1") + forbidRewriteMutations(t, mock) + mock.RebaseAbortFn = func() error { t.Fatal("must not abort an incompatible lifecycle"); return nil } + mock.RebaseContinueFn = func(git.RebaseOpts) error { + t.Fatal("must not continue an incompatible lifecycle") + return nil + } + restore := git.SetOps(mock) + defer restore() + state := &rebaseState{ + ExecutionMode: mode, Phase: "conflict", OriginalBranch: "b1", ConflictBranch: "b1", + OriginalRefs: map[string]string{"b1": "before"}, + } + if mode != originOnlyRebaseMode { + state.Worktrees = &worktree.Context{Origin: worktree.Location{Path: dir, ID: "."}} + } + require.NoError(t, saveRebaseState(dir, state)) + before, err := os.ReadFile(filepath.Join(dir, rebaseStateFile)) + require.NoError(t, err) + cfg, outR, errR := config.NewTestConfig() + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{cont: action == "continue", abort: action == "abort"}), ErrSilent) + + _, output := commandOutput(t, cfg, outR, errR) + if mode == originOnlyRebaseMode { + assert.Contains(t, output, "no recorded worktree") + } else { + assert.Contains(t, output, "matching gh-stack build") + assert.Contains(t, output, fmt.Sprintf("%q", dir)) + } + after, err := os.ReadFile(filepath.Join(dir, rebaseStateFile)) + require.NoError(t, err) + assert.Equal(t, before, after) + }) + } + } +} diff --git a/cmd/submit.go b/cmd/submit.go index 3a26aa0..5d2f1ce 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -74,10 +74,14 @@ In the editor, new PRs default to ready for review; switch any to draft with the } func runSubmit(cfg *config.Config, opts *submitOptions) error { - gitDir, err := git.GitDir() + release, err := beginStackMutation(cfg, "submit") if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err + } + defer release() + gitDir, err := stackStateDir(cfg) + if err != nil { + return err } sf, err := stack.Load(gitDir) @@ -177,12 +181,18 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // If a modification is pending, delete the old remote stack first so that // PR base updates are allowed and force-pushes don't trigger auto-merges. + pendingModify := false if stacksAvailable { - if err := handlePendingModify(cfg, client, s, gitDir); err != nil { + pendingModify, err = handlePendingModify(cfg, client, s, gitDir) + if err != nil { if errors.Is(err, errInterrupt) { return ErrSilent } // DeleteStack or other failure — don't continue with stale state + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } return ErrSilent } } @@ -222,6 +232,8 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // Sequential pushing ensures each branch's base is up-to-date on the // remote before the next branch is pushed, preventing race conditions. cfg.Printf("Pushing to %s...", remote) + allPRsSubmitted := len(queued) == 0 + submittedPRs := make(map[string]int, len(activeBranches)) for i, b := range s.Branches { if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() { continue @@ -235,7 +247,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // Find or create PR, and fix base if needed baseBranch := s.ActiveBaseBranch(b.Branch) - if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts); err != nil { + submitted, err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts) + allPRsSubmitted = allPRsSubmitted && submitted + if submitted { + submittedPRs[b.Branch] = s.Branches[i].PullRequest.Number + } + if err != nil { if errors.Is(err, errInterrupt) { printInterrupt(cfg) return ErrSilent @@ -245,17 +262,33 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { } // Create or update the stack on GitHub - if stacksAvailable { - syncStack(cfg, client, s) - clearPendingModifyState(cfg, gitDir) + stackSynced := false + if stacksAvailable && (!pendingModify || allPRsSubmitted) { + stackSynced = syncStack(cfg, client, s) } // Update base commit hashes and sync PR state updateBaseSHAs(s) _ = syncStackPRs(cfg, s) + for _, b := range s.Branches { + if number, submitted := submittedPRs[b.Branch]; submitted && + (b.PullRequest == nil || b.PullRequest.Number != number) { + allPRsSubmitted = false + } + } if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) + return stackSaveError(cfg, err) + } + if pendingModify { + if !allPRsSubmitted || (!stackSynced && len(s.Branches) != 1) { + cfg.Errorf("modify is not fully submitted; recovery state was retained") + cfg.Printf("Finish submitting all remaining PRs, then retry `%s`", cfg.ColorCyan("gh stack submit")) + return ErrSilent + } + if err := clearPendingModifyState(cfg, s, gitDir); err != nil { + return err + } } cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) @@ -329,33 +362,40 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack // // drafts holds optional per-branch overrides from the interactive editor. When // a NEW branch has been deselected in the editor, it is pushed for stack -// consistency but no PR is created for it. -func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error { +// consistency but no PR is created for it. The returned bool is true only when +// the PR and all requested updates were submitted successfully. +func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) (bool, error) { b := s.Branches[i] pr, err := client.FindPRForBranch(b.Branch) if err != nil { cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) - return nil + return false, err } if pr == nil { // A NEW branch the user deselected in the editor: pushed for stack // consistency, but intentionally left without a PR. if d := drafts[b.Branch]; d != nil && !d.Include { - return nil + return false, nil } - return createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts) + err := createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts) + return err == nil, err } // PR exists — record it and fix base if needed. - if s.Branches[i].PullRequest == nil { - s.Branches[i].PullRequest = &stack.PullRequestRef{ - Number: pr.Number, - ID: pr.ID, - URL: pr.URL, - } + if pr.Number <= 0 { + err := fmt.Errorf("PR lookup for %s returned no pull request number", b.Branch) + cfg.Warningf("%s", err) + return false, err } + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + } + complete := true + var failures []error // Disable auto-merge before adding this PR to a stack. A PR with // auto-merge enabled would merge on its own, breaking the stack. @@ -363,6 +403,7 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int if err := client.DisableAutoMerge(pr.ID); err != nil { cfg.Warningf("failed to disable auto-merge for PR %s: %v", cfg.PRLink(pr.Number, pr.URL), err) + failures = append(failures, err) } else { cfg.Warningf("Disabled auto-merge for PR %s (incompatible with stacked PRs)", cfg.PRLink(pr.Number, pr.URL)) @@ -374,10 +415,12 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int // Stack API owns base relationships — can't update directly. cfg.Warningf("PR %s has base %q (expected %q) but cannot update while stacked", cfg.PRLink(pr.Number, pr.URL), pr.BaseRefName, baseBranch) + complete = false } else { if err := client.UpdatePRBase(pr.Number, baseBranch); err != nil { cfg.Warningf("failed to update base branch for PR %s: %v", cfg.PRLink(pr.Number, pr.URL), err) + failures = append(failures, err) } else { cfg.Successf("Updated base branch for PR %s to %s", cfg.PRLink(pr.Number, pr.URL), baseBranch) @@ -392,13 +435,14 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int if err := client.MarkPRReadyForReview(pr.ID); err != nil { cfg.Warningf("failed to mark PR %s as ready for review: %v", cfg.PRLink(pr.Number, pr.URL), err) + failures = append(failures, err) } else { cfg.Successf("Marked PR %s as ready for review", cfg.PRLink(pr.Number, pr.URL)) } } - return nil + return complete && len(failures) == 0, errors.Join(failures...) } // createPR creates a new PR for the branch at index i. @@ -435,7 +479,12 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft) if createErr != nil { cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) - return nil + return createErr + } + if newPR == nil || newPR.Number <= 0 { + err := fmt.Errorf("creating PR for %s returned no pull request number", b.Branch) + cfg.Warningf("%s", err) + return err } cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) s.Branches[i].PullRequest = &stack.PullRequestRef{ @@ -630,15 +679,23 @@ func mergedPRNumbers(s *stack.Stack) map[int]bool { // handlePendingModify handles the stack recreation after a modify operation. // It deletes the old remote stack and clears s.ID so syncStack creates a new -// one. The state file is NOT cleared here — it is cleared after syncStack -// succeeds, ensuring retry safety. -func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) error { +// one. The bool identifies a matching pending modification. Its journal is +// cleared only after the caller verifies all required submission outcomes. +func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) (bool, error) { state, err := modify.LoadState(gitDir) - if err != nil || state == nil { - return nil // No modify state — nothing to do + if err != nil { + cfg.Errorf("reading modify recovery state: %s", err) + return false, ErrModifyRecovery + } + if state == nil { + return false, nil // No modify state — nothing to do } if state.Phase != modify.PhasePendingSubmit { - return nil // Not in pending_submit phase + cfg.Errorf("a modify session needs recovery; run `gh stack modify --continue` or `gh stack modify --abort`") + return false, ErrModifyRecovery + } + if !modify.MatchesStack(state, s) { + return false, nil } // Prompt for confirmation before overwriting the remote stack @@ -648,14 +705,14 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S if promptErr != nil { if isInterruptError(promptErr) { printInterrupt(cfg) - return errInterrupt + return true, errInterrupt } - return promptErr + return true, promptErr } if !proceed { cfg.Printf("Skipping stack recreation — run `%s` when ready", cfg.ColorCyan("gh stack submit")) - return errInterrupt + return true, errInterrupt } } @@ -665,38 +722,57 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S if lookupErr != nil { cfg.Warningf("Failed to look up existing stack: %v", lookupErr) cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) - return lookupErr + return true, lookupErr } if !found { cfg.Printf("Previous stack already deleted on GitHub") - } else if _, _, err := client.Unstack(number); err != nil { + } else if _, dissolved, err := client.Unstack(number); err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { cfg.Printf("Previous stack already deleted on GitHub") } else { cfg.Warningf("Failed to delete existing stack: %v", err) cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) - return err + return true, err } + } else if !dissolved { + cfg.Errorf("the previous stack still has pull requests queued for merge or with auto-merge enabled; it cannot be recreated yet") + return true, ErrConflict } else { cfg.Successf("Cleared existing stack on GitHub") } - // Clear the old stack ID so syncStack creates a new one - s.ID = "" - s.Number = 0 } + // Record branch identity before the replacement receives a different ID. + // A retry may still load the old catalog ID after the old stack was deleted. + state.RecordStack(s) + state.PriorRemoteStackID = "" + if err := modify.SaveState(gitDir, state); err != nil { + cfg.Errorf("saving modify recovery state: %s", err) + return true, ErrModifyRecovery + } + s.ID = "" + s.Number = 0 - return nil + return true, nil } // clearPendingModifyState clears the modify state file after a successful submit. -// Called after syncStack succeeds to ensure retry safety. -func clearPendingModifyState(cfg *config.Config, gitDir string) { - if !modify.StateExists(gitDir) { - return +// The caller must verify all required PR updates and save the catalog first. +func clearPendingModifyState(cfg *config.Config, s *stack.Stack, gitDir string) error { + state, err := modify.LoadState(gitDir) + if err != nil { + cfg.Errorf("reading modify recovery state: %s", err) + return ErrModifyRecovery } - modify.ClearState(gitDir) - cfg.Successf("Stack recreated on GitHub to match local state") + if state == nil || state.Phase != modify.PhasePendingSubmit || !modify.MatchesStack(state, s) { + return nil + } + if err := modify.ClearState(gitDir); err != nil { + cfg.Errorf("clearing modify recovery state: %s", err) + return ErrModifyRecovery + } + cfg.Successf("Changes from modify submitted to GitHub") + return nil } // syncStack creates or updates a stack on GitHub from the active PRs. diff --git a/cmd/submit_test.go b/cmd/submit_test.go index d2c4627..4918d59 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -1824,6 +1824,385 @@ func newPendingSubmitState(priorStackID string) *modify.StateFile { } } +func TestSubmit_PendingModifyCompletion(t *testing.T) { + for _, tc := range []struct { + name string + branches int + newPR bool + failure string + priorDeleted bool + }{ + {name: "one existing PR", branches: 1}, + {name: "one new PR", branches: 1, newPR: true}, + {name: "retry after old stack deletion", branches: 1, newPR: true, priorDeleted: true}, + {name: "one PR failed push", branches: 1, failure: "push"}, + {name: "one PR failed lookup", branches: 1, failure: "lookup"}, + {name: "one PR failed creation", branches: 1, newPR: true, failure: "create"}, + {name: "one PR invalid creation result", branches: 1, newPR: true, failure: "invalid-create"}, + {name: "one PR failed base update", branches: 1, failure: "base"}, + {name: "one PR failed auto-merge update", branches: 1, failure: "auto-merge"}, + {name: "one PR failed ready update", branches: 1, failure: "ready"}, + {name: "one PR failed catalog save", branches: 1, failure: "save"}, + {name: "one PR lost during final refresh", branches: 1, failure: "refresh"}, + {name: "multiple PRs complete", branches: 3}, + {name: "multiple PRs failed lookup", branches: 3, failure: "lookup"}, + {name: "multiple PRs failed creation", branches: 3, newPR: true, failure: "create"}, + {name: "multiple PRs failed base update", branches: 3, failure: "base"}, + {name: "multiple PRs failed stack creation", branches: 3, failure: "stack"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{ID: "42", Number: 7, Trunk: stack.BranchRef{Branch: "main"}} + prs := make(map[string]*github.PullRequest) + oldNumbers := []int{99, 100} + target := fmt.Sprintf("b%d", tc.branches) + for i := 0; i < tc.branches; i++ { + name := fmt.Sprintf("b%d", i+1) + br := stack.BranchRef{Branch: name} + if !tc.newPR || name != target { + base := "main" + if i > 0 { + base = fmt.Sprintf("b%d", i) + } + if name == target { + base = "removed" + } + prs[name] = &github.PullRequest{ + Number: 10 + i, ID: fmt.Sprintf("PR_%d", 10+i), State: "OPEN", + HeadRefName: name, BaseRefName: base, + } + if tc.failure == "auto-merge" && name == target { + prs[name].AutoMergeRequest = &github.AutoMergeRequest{EnabledAt: "2026-09-29T00:00:00Z"} + } + prs[name].IsDraft = tc.failure == "ready" && name == target + br.PullRequest = &stack.PullRequestRef{Number: 10 + i, ID: prs[name].ID} + oldNumbers = append(oldNumbers, 10+i) + } + s.Branches = append(s.Branches, br) + } + writeStackFile(t, dir, s) + pending := newPendingSubmitState("42") + if tc.priorDeleted { + pending.PriorRemoteStackID = "" + } + pending.RecordStack(&s) + saveModifyState(t, dir, pending) + mock := newSubmitMock(dir, "b1") + var pushes []string + mock.PushFn = func(_ string, branches []string, _, _ bool) error { + pushes = append(pushes, branches[0]) + if tc.failure == "push" && branches[0] == target { + return assert.AnError + } + return nil + } + restore := git.SetOps(mock) + defer restore() + oldTimeout := stack.LockTimeout + stack.LockTimeout = 0 + defer func() { stack.LockTimeout = oldTimeout }() + var catalogLock *stack.FileLock + defer func() { + if catalogLock != nil { + catalogLock.Unlock() + } + }() + oldDeleted := tc.priorDeleted + unstackCalls, createdPRs, baseUpdates := 0, 0, 0 + var replacement *github.RemoteStack + client := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + if !oldDeleted { + return []github.RemoteStack{{ID: 42, Number: 7, PullRequests: oldNumbers}}, nil + } + if replacement != nil { + return []github.RemoteStack{*replacement}, nil + } + return nil, nil + }, + GetStackFn: func(number int) (*github.RemoteStack, error) { + if number == 7 && !oldDeleted { + return &github.RemoteStack{ID: 42, Number: 7, PullRequests: oldNumbers}, nil + } + if number == 8 && replacement != nil { + return replacement, nil + } + return nil, &api.HTTPError{StatusCode: 404} + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + if tc.failure == "refresh" && baseUpdates > 0 && number == prs[target].Number { + return nil, nil + } + for _, pr := range prs { + if pr.Number == number { + return pr, nil + } + } + return &github.PullRequest{Number: number, HeadRefName: fmt.Sprintf("removed-%d", number), State: "OPEN"}, nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + if branch == target && (tc.failure == "lookup" || (tc.failure == "refresh" && baseUpdates > 0)) { + return nil, assert.AnError + } + return prs[branch], nil + }, + UnstackFn: func(number int) (*github.RemoteStack, bool, error) { + require.Equal(t, 7, number) + unstackCalls++ + oldDeleted = true + return nil, true, nil + }, + CreatePRFn: func(base, head, _, _ string, _ bool) (*github.PullRequest, error) { + createdPRs++ + if tc.failure == "create" { + return nil, assert.AnError + } + if tc.failure == "invalid-create" { + return nil, nil + } + pr := &github.PullRequest{Number: 50, ID: "PR_50", HeadRefName: head, BaseRefName: base, State: "OPEN"} + prs[head] = pr + return pr, nil + }, + UpdatePRBaseFn: func(number int, base string) error { + baseUpdates++ + if tc.failure == "base" { + return assert.AnError + } + require.Equal(t, prs[target].Number, number) + prs[target].BaseRefName = base + if tc.failure == "save" { + var err error + catalogLock, err = stack.Lock(dir) + require.NoError(t, err) + } + return nil + }, + DisableAutoMergeFn: func(string) error { return assert.AnError }, + MarkPRReadyForReviewFn: func(string) error { return assert.AnError }, + CreateStackFn: func(numbers []int) (*github.RemoteStack, error) { + require.GreaterOrEqual(t, len(numbers), 2) + if tc.failure == "stack" { + return nil, assert.AnError + } + replacement = &github.RemoteStack{ID: 77, Number: 8, PullRequests: numbers} + return replacement, nil + }, + } + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = client + + err := runSubmit(cfg, &submitOptions{auto: true, open: tc.failure == "ready"}) + + _, output := commandOutput(t, cfg, outR, errR) + if tc.failure != "" { + assert.Error(t, err, output) + assert.FileExists(t, modify.StatePath(dir)) + retained, loadErr := modify.LoadState(dir) + require.NoError(t, loadErr) + require.NotNil(t, retained) + assert.Equal(t, modify.PhasePendingSubmit, retained.Phase) + assert.NotContains(t, output, "Stack recreated") + assert.NotContains(t, output, "Changes from modify submitted") + assert.Nil(t, replacement, "an incomplete modification must not create a partial replacement stack") + } else { + require.NoError(t, err, output) + assert.NoFileExists(t, modify.StatePath(dir)) + assert.Len(t, pushes, tc.branches) + saved, loadErr := stack.Load(dir) + require.NoError(t, loadErr) + require.NotNil(t, saved.Stacks[0].Branches[tc.branches-1].PullRequest) + assert.Equal(t, prs[target].Number, saved.Stacks[0].Branches[tc.branches-1].PullRequest.Number) + if tc.branches == 1 { + assert.Empty(t, saved.Stacks[0].ID) + assert.Nil(t, replacement) + assert.NotContains(t, output, "Stack recreated") + } + assert.Contains(t, output, "Changes from modify submitted") + if tc.newPR { + assert.Equal(t, 1, createdPRs) + } else { + assert.Equal(t, 1, baseUpdates) + } + } + if tc.priorDeleted { + assert.Zero(t, unstackCalls) + } else { + assert.Equal(t, 1, unstackCalls) + } + }) + } +} + +func TestSubmit_PRFailureWithoutPendingModifyRemainsBestEffort(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + mock := newSubmitMock(dir, "b1") + pushes := 0 + mock.PushFn = func(string, []string, bool, bool) error { pushes++; return nil } + restore := git.SetOps(mock) + defer restore() + cfg, outR, errR := config.NewTestConfig() + creations := 0 + cfg.GitHubClientOverride = &github.MockClient{ + CreatePRFn: func(_, head, _, _ string, _ bool) (*github.PullRequest, error) { + creations++ + if head == "b2" { + return nil, assert.AnError + } + return &github.PullRequest{Number: 10, ID: "PR_10", HeadRefName: head}, nil + }, + } + + require.NoError(t, runSubmit(cfg, &submitOptions{auto: true})) + + _, output := commandOutput(t, cfg, outR, errR) + assert.Equal(t, 2, pushes) + assert.Equal(t, 2, creations) + assert.Contains(t, output, "failed to create PR for b2") + assert.NoFileExists(t, modify.StatePath(dir)) +} + +func TestPendingModify_UnrelatedStackRemainsUntouched(t *testing.T) { + dir := t.TempDir() + pending := newPendingSubmitState("123") + saveModifyState(t, dir, pending) + s := &stack.Stack{ID: "456", Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "other"}}} + cfg, outR, errR := config.NewTestConfig() + client := &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + t.Fatal("must never unstack another stack's pending modification") + return nil, false, nil + }, + } + handled, err := handlePendingModify(cfg, client, s, dir) + require.NoError(t, err) + assert.False(t, handled) + require.NoError(t, clearPendingModifyState(cfg, s, dir)) + after, err := modify.LoadState(dir) + require.NoError(t, err) + assert.Equal(t, pending, after) + assert.Equal(t, "456", s.ID) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.NotContains(t, diagnostics, "recreated") +} + +func TestPendingModify_CorruptStateFailsClosed(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(modify.StatePath(dir), []byte("{"), 0600)) + s := &stack.Stack{ID: "123"} + cfg, outR, errR := config.NewTestConfig() + handled, err := handlePendingModify(cfg, &github.MockClient{}, s, dir) + assert.ErrorIs(t, err, ErrModifyRecovery) + assert.False(t, handled) + assert.ErrorIs(t, clearPendingModifyState(cfg, s, dir), ErrModifyRecovery) + assert.FileExists(t, modify.StatePath(dir)) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) +} + +func TestPendingModify_RetryAfterOldStackDeletion(t *testing.T) { + dir := t.TempDir() + s := &stack.Stack{ID: "123", Number: 7, Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}} + state := newPendingSubmitState("") + state.RecordStack(s) + saveModifyState(t, dir, state) + cfg, outR, errR := config.NewTestConfig() + client := &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + t.Fatal("the old stack was already deleted") + return nil, false, nil + }, + } + handled, err := handlePendingModify(cfg, client, s, dir) + require.NoError(t, err) + assert.True(t, handled) + assert.Empty(t, s.ID, "the catalog can still hold the old ID on a retry") + assert.Zero(t, s.Number) + s.ID, s.Number = "456", 8 + require.NoError(t, clearPendingModifyState(cfg, s, dir)) + assert.NoFileExists(t, modify.StatePath(dir)) + commandOutput(t, cfg, outR, errR) +} + +func TestPendingModify_PartialUnstackPreservesState(t *testing.T) { + dir := t.TempDir() + saveModifyState(t, dir, newPendingSubmitState("123")) + s := &stack.Stack{ID: "123", Number: 7} + cfg, outR, errR := config.NewTestConfig() + client := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 123, Number: 7}}, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { return nil, false, nil }, + } + handled, err := handlePendingModify(cfg, client, s, dir) + assert.ErrorIs(t, err, ErrConflict) + assert.True(t, handled) + assert.Equal(t, "123", s.ID) + assert.Equal(t, 7, s.Number) + state, err := modify.LoadState(dir) + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, "123", state.PriorRemoteStackID) + commandOutput(t, cfg, outR, errR) +} + +func TestSubmit_UnrelatedPendingModifyIsPreserved(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{ + ID: "456", Number: 8, Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, + }, + } + writeStackFile(t, dir, s) + saveModifyState(t, dir, newPendingSubmitState("123")) + before, err := os.ReadFile(modify.StatePath(dir)) + require.NoError(t, err) + restore := git.SetOps(newSubmitMock(dir, "b1")) + defer restore() + cfg, outR, errR := config.NewTestConfig() + prs := map[int]*github.PullRequest{ + 10: {Number: 10, State: "OPEN", HeadRefName: "b1", BaseRefName: "main"}, + 11: {Number: 11, State: "OPEN", HeadRefName: "b2", BaseRefName: "b1"}, + } + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 456, Number: 8, PullRequests: []int{10, 11}}}, nil + }, + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 456, Number: 8, PullRequests: []int{10, 11}}, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + return prs[number], nil + }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + for _, pr := range prs { + if pr.HeadRefName == branch { + return pr, nil + } + } + return nil, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + t.Fatal("submitting another stack must not consume pending modification") + return nil, false, nil + }, + } + require.NoError(t, runSubmit(cfg, &submitOptions{auto: true})) + after, err := os.ReadFile(modify.StatePath(dir)) + require.NoError(t, err) + assert.Equal(t, before, after) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.NotContains(t, diagnostics, "recreated") +} + func TestHandlePendingModify_DeletesOldStack(t *testing.T) { gitDir := t.TempDir() @@ -1846,8 +2225,9 @@ func TestHandlePendingModify_DeletesOldStack(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - err := handlePendingModify(cfg, client, s, gitDir) + handled, err := handlePendingModify(cfg, client, s, gitDir) require.NoError(t, err) + assert.True(t, handled) assert.Equal(t, 42, unstackedNumber) assert.Equal(t, "", s.ID) } @@ -1870,8 +2250,9 @@ func TestHandlePendingModify_NoStateFile(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - err := handlePendingModify(cfg, client, s, gitDir) + handled, err := handlePendingModify(cfg, client, s, gitDir) assert.NoError(t, err) + assert.False(t, handled) assert.False(t, deleteCalled, "Unstack should not be called when no state file exists") assert.Equal(t, "stack-123", s.ID, "stack ID should remain unchanged") } @@ -1900,8 +2281,9 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - err := handlePendingModify(cfg, client, s, gitDir) - assert.NoError(t, err) + handled, err := handlePendingModify(cfg, client, s, gitDir) + assert.ErrorIs(t, err, ErrModifyRecovery) + assert.False(t, handled) assert.False(t, deleteCalled, "Unstack should not be called for non-pending_submit phase") assert.Equal(t, "stack-99", s.ID, "stack ID should remain unchanged") } @@ -1926,8 +2308,9 @@ func TestHandlePendingModify_DeleteFails(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - err := handlePendingModify(cfg, client, s, gitDir) + handled, err := handlePendingModify(cfg, client, s, gitDir) assert.Error(t, err) + assert.True(t, handled) assert.Equal(t, "456", s.ID, "stack ID should NOT be cleared on delete failure") } @@ -1955,8 +2338,9 @@ func TestHandlePendingModify_Delete404(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - err := handlePendingModify(cfg, client, s, gitDir) + handled, err := handlePendingModify(cfg, client, s, gitDir) require.NoError(t, err, "404 should be treated as success (stack already deleted)") + assert.True(t, handled) assert.Equal(t, "", s.ID, "stack ID should be cleared after 404") } @@ -1970,7 +2354,7 @@ func TestClearPendingModifyState_ClearsFile(t *testing.T) { defer cfg.Out.Close() defer cfg.Err.Close() - clearPendingModifyState(cfg, gitDir) + require.NoError(t, clearPendingModifyState(cfg, &stack.Stack{ID: "stack-789"}, gitDir)) assert.False(t, modify.StateExists(gitDir), "state file should be removed") } @@ -1983,7 +2367,7 @@ func TestClearPendingModifyState_NoFile(t *testing.T) { defer cfg.Err.Close() // Should not panic or error. - clearPendingModifyState(cfg, gitDir) + require.NoError(t, clearPendingModifyState(cfg, &stack.Stack{}, gitDir)) assert.False(t, modify.StateExists(gitDir)) } @@ -2585,7 +2969,30 @@ func TestEnsurePR_DeselectedNewBranchSkipsCreate(t *testing.T) { "b1": {Branch: "b1", Include: false}, } - err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts) + submitted, err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts) require.NoError(t, err) + assert.False(t, submitted) assert.Nil(t, s.Branches[0].PullRequest, "no PR should be recorded for a deselected branch") } + +func TestEnsurePR_DeselectedMissingPRIsIncompleteWithCachedAssociation(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}}, + } + cfg, outR, errR := config.NewTestConfig() + client := &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(string, string, string, string, bool) (*github.PullRequest, error) { + t.Fatal("a deselected branch must not create a PR") + return nil, nil + }, + } + drafts := map[string]*submitview.PRDraft{"b1": {Branch: "b1", Include: false}} + + submitted, err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts) + + require.NoError(t, err) + assert.False(t, submitted, "a cached PR count must not turn a deselected missing PR into success") + commandOutput(t, cfg, outR, errR) +} diff --git a/cmd/switch.go b/cmd/switch.go index 80e72e1..473a180 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -1,13 +1,13 @@ package cmd import ( + "errors" "fmt" "strings" "github.com/AlecAivazis/survey/v2" "github.com/cli/go-gh/v2/pkg/text" "github.com/github/gh-stack/internal/config" - "github.com/github/gh-stack/internal/git" "github.com/spf13/cobra" ) @@ -36,7 +36,7 @@ To move one branch down or up without an interactive picker, use func runSwitch(cfg *config.Config) error { result, err := loadStack(cfg, "") if err != nil { - return ErrNotInStack + return stackLookupError(err) } s := result.Stack @@ -87,7 +87,11 @@ func runSwitch(cfg *config.Config) error { return nil } - if err := git.CheckoutBranch(targetBranch); err != nil { + if err := checkoutWorktreeBranch(cfg, targetBranch, false); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } cfg.Errorf("failed to checkout %s: %v", targetBranch, err) return ErrSilent } diff --git a/cmd/switch_test.go b/cmd/switch_test.go index d626204..da98f14 100644 --- a/cmd/switch_test.go +++ b/cmd/switch_test.go @@ -11,6 +11,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestSwitch_ForeignOwnerGuidance(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), t.TempDir() + writeStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "b2"}}, nil }, + CheckoutBranchFn: func(string) error { + t.Fatal("must not check out another worktree's branch") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.SelectFn = func(string, string, []string) (int, error) { return 0, nil } + assert.ErrorIs(t, runSwitch(cfg), ErrInvalidArgs) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, owner) + assert.Contains(t, diagnostics, "cd --") + assert.NotContains(t, diagnostics, "Switched to") +} + func TestSwitch_SwitchesToSelectedBranch(t *testing.T) { gitDir := t.TempDir() var checkedOut string diff --git a/cmd/sync.go b/cmd/sync.go index 898e356..ff11626 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -10,6 +10,7 @@ import ( "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" "github.com/spf13/cobra" ) @@ -62,7 +63,12 @@ than two PRs exist yet). Use --prune to delete local branches for merged PRs. Stack metadata is preserved so that rebase and display logic continue to work correctly. If you are on a branch that would be pruned, your checkout is moved to -the first active branch in the stack, or the trunk if all are merged.`, +the first active branch in the stack, or the trunk if all are merged. + +All stack branches and the trunk must currently be unoccupied or checked out +in this worktree. This includes remote-added branches and merged members. +Cross-worktree rewrites are refused before requested mutations, after +shared-catalog migration and any discovery fetches.`, RunE: func(cmd *cobra.Command, args []string) error { return runSync(cfg, opts) }, @@ -75,9 +81,14 @@ the first active branch in the stack, or the trunk if all are merged.`, } func runSync(cfg *config.Config, opts *syncOptions) error { + release, err := beginStackMutation(cfg, "sync") + if err != nil { + return err + } + defer release() result, err := loadStack(cfg, "") if err != nil { - return ErrNotInStack + return stackLookupError(err) } gitDir := result.GitDir @@ -89,6 +100,15 @@ func runSync(cfg *config.Config, opts *syncOptions) error { sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch + originalTrunk := s.Trunk.Branch + if err := requireLocalBranches(cfg, s.BranchNames()); err != nil { + return err + } + ctx, err := worktree.New() + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } // Resolve remote once for fetch and push remote, err := pickRemote(cfg, currentBranch, opts.remote) @@ -98,6 +118,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } return ErrSilent } + trunkBranch, err := normalizeTrunkBranch(s.Trunk.Branch, remote) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if err := requireLocalBranches(cfg, []string{trunkBranch}); err != nil { + return err + } // --- Step 1: Fetch --- // Enable git rerere so conflict resolutions are remembered. @@ -143,7 +171,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if cb, cbErr := git.CurrentBranch(); cbErr == nil { currentBranch = cb } - + trunkBranch, err = normalizeTrunkBranch(s.Trunk.Branch, remote) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if err := requireLocalBranches(cfg, append(s.BranchNames(), trunkBranch)); err != nil { + return err + } // --- Step 2: Resolve trunk --- trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch) if err != nil { @@ -157,6 +192,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { needsRebase := trunk.Moved || len(updatedBranches) > 0 || stackNeedsRebase(s, trunk.Ref) rebased := false var originalRefs map[string]string + var state *rebaseState if needsRebase { cfg.Printf("") cfg.Printf("Rebasing stack ...") @@ -169,6 +205,16 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Errorf("Could not resolve branch SHAs: %v", err) return ErrSilent } else { + state = newWorktreeRebaseState(s, ctx, currentBranch, originalRefs, trunk, 0, len(s.Branches)) + if s.Trunk.Branch != originalTrunk { + if err := stack.Save(gitDir, sf); err != nil { + return handleSaveError(cfg, err) + } + } + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } result := cascadeRebase(cascadeRebaseOpts{ Cfg: cfg, Stack: s, @@ -180,42 +226,20 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if result.Err != nil { cfg.Errorf("%v", result.Err) - if result.Rebased { - if err := restoreRebaseRefs(cfg, currentBranch, originalRefs); err != nil { - return err - } - } else { - _ = git.CheckoutBranch(currentBranch) + if err := abortRebase(cfg, gitDir); err != nil { + return err } - stack.SaveNonBlocking(gitDir, sf) return ErrSilent } if result.Conflicted { // Abort and restore everything — sync is non-interactive. - inProgress, err := git.IsRebaseInProgress() - if err != nil { - cfg.Errorf("failed to check rebase state: %s", err) - return ErrSilent - } - if inProgress { - _ = git.RebaseAbort() - } - restoreErrors, err := restoreBranches(originalRefs) - if err != nil { - cfg.Errorf("%s", err) - return ErrSilent - } - _ = git.CheckoutBranch(currentBranch) - cfg.Errorf("Conflict detected rebasing %s onto %s", result.ConflictBranch, result.ConflictBase) - reportRestoreStatus(cfg, restoreErrors) + if err := abortRebase(cfg, gitDir); err != nil { + return err + } cfg.Printf(" Run `%s` to resolve conflicts interactively.", cfg.ColorCyan("gh stack rebase")) - - // Persist refreshed PR state even on conflict, then bail out - // before pushing or reporting success. - stack.SaveNonBlocking(gitDir, sf) return ErrConflict } @@ -223,20 +247,22 @@ func runSync(cfg *config.Config, opts *syncOptions) error { rebased = true } } - _ = git.CheckoutBranch(currentBranch) } if unstacked := verifyStacked(s, trunk.Ref, 0, len(s.Branches)); len(unstacked) > 0 { - _ = git.CheckoutBranch(currentBranch) reportUnstacked(cfg, trunk.Ref, unstacked) - if rebased && originalRefs != nil { - if err := restoreRebaseRefs(cfg, currentBranch, originalRefs); err != nil { + if state != nil { + if err := abortRebase(cfg, gitDir); err != nil { return err } } - stack.SaveNonBlocking(gitDir, sf) return ErrSilent } + if state != nil { + if err := finishOriginRebase(cfg, gitDir, state, sf, s); err != nil { + return err + } + } // --- Step 4: Push --- cfg.Printf("") @@ -414,7 +440,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } // --- Step 7: Update base SHAs and save --- - updateBaseSHAs(s) + updateBaseSHAsWithTrunk(s, trunk.SHA) if err := stack.Save(gitDir, sf); err != nil { return handleSaveError(cfg, err) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 8f842ea..14b76b7 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1,8 +1,11 @@ package cmd import ( + "errors" "fmt" "io" + "os" + "path/filepath" "strings" "testing" @@ -2604,3 +2607,167 @@ func TestSync_MergedBranchPruned_NoFalseDivergence(t *testing.T) { assert.Empty(t, created) assert.NotContains(t, output, "diverged") } + +func TestSync_ForeignTargetsRefusedBeforeMutation(t *testing.T) { + for _, owner := range []string{"b1", "main"} { + t.Run(owner, func(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + }, + }) + before, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + mock := newSyncMock(dir, "b2") + mockForeignOwner(t, mock, dir, "b2", owner) + forbidRewriteMutations(t, mock) + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = &github.MockClient{} + }) + require.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "cross-worktree rebase and sync are not supported yet") + after, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) + }) + } +} + +func TestSync_ForeignRemoteTargetsRefusedBeforeReconciliation(t *testing.T) { + for _, tc := range []struct { + name string + remote []int + choice int + duringPrompt bool + }{ + {name: "remote append", remote: []int{101, 102, 103}}, + {name: "replace local", remote: []int{101, 103}, choice: 0, duringPrompt: true}, + {name: "delete remote", remote: []int{101, 103}, choice: 1, duringPrompt: true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + ID: "9", Number: 9, Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + before, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + mock := newSyncMock(dir, "b2") + owner := mockForeignOwner(t, mock, dir, "b2", "b3") + owned := !tc.duringPrompt + worktrees := mock.WorktreesFn + mock.WorktreesFn = func() ([]git.Worktree, error) { + trees, err := worktrees() + if !owned { + trees[1].Branch = "unrelated" + } + return trees, err + } + forbidRewriteMutations(t, mock) + fetches := 0 + mock.FetchBranchesFn = func(string, []string) error { fetches++; return nil } + lookups := 0 + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + lookups++ + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: tc.remote}}, nil + }, + FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3"}), + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + t.Fatal("must not mutate the remote stack") + return nil, false, nil + }, + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(string, string, []string) (int, error) { + require.True(t, tc.duringPrompt, "ownership preflight must precede reconciliation choices") + owned = true + return tc.choice, nil + } + }) + + require.ErrorIs(t, err, ErrInvalidArgs) + assert.Positive(t, fetches, "discovery fetch is an allowed prerequisite") + assert.Positive(t, lookups) + assert.Contains(t, output, owner) + after, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) + }) + } +} + +func TestSync_InvalidOriginRefusesBeforeReconciliation(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + ID: "9", Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + mock := newSyncMock(dir, "b1") + mock.RootDirFn = func() (string, error) { return "", errors.New("origin unavailable") } + forbidRewriteMutations(t, mock) + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = &github.MockClient{ListStacksFn: func() ([]github.RemoteStack, error) { + t.Fatal("origin validation must precede remote reconciliation") + return nil, nil + }} + }) + + require.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "origin unavailable") +} + +func TestSync_RollbackFailureRetainsOriginRecovery(t *testing.T) { + dir := t.TempDir() + writeStackFile(t, dir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + current := "b2" + refs := map[string]string{"main": "trunk", "b1": "old-b1", "b2": "old-b2"} + busy, failReset := false, true + mock := newSyncMock(dir, current) + mock.CurrentBranchFn = func() (string, error) { return current, nil } + mock.RevParseFn = func(ref string) (string, error) { return refs[strings.TrimPrefix(ref, "origin/")], nil } + mock.IsAncestorFn = func(a, b string) (bool, error) { return a == "old-b1" && b == "b2", nil } + mock.CheckoutBranchFn = func(branch string) error { current = branch; return nil } + mock.RebaseFn = func(string, git.RebaseOpts) error { refs[current] = "rebased-b1"; return nil } + mock.RebaseOntoFn = func(_, _, branch string, _ git.RebaseOpts) error { + current, busy = branch, true + return errors.New("conflict") + } + mock.IsRebaseInProgressFn = func() (bool, error) { return busy, nil } + mock.RebaseAbortFn = func() error { busy = false; return nil } + mock.ResetHardFn = func(sha string) error { + if failReset { + return errors.New("reset failed") + } + refs[current] = sha + return nil + } + mock.PushFn = func(string, []string, bool, bool) error { t.Fatal("must not push after failed rollback"); return nil } + restore := git.SetOps(mock) + defer restore() + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runSync(cfg, &syncOptions{}), ErrSilent) + state, err := loadRebaseState(dir) + require.NoError(t, err) + assert.Equal(t, "restoring", state.Phase) + assert.Equal(t, originOnlyRebaseMode, state.ExecutionMode) + require.NotNil(t, state.Worktrees) + assert.Equal(t, "rebased-b1", refs["b1"]) + + failReset = false + require.NoError(t, runRebase(cfg, &rebaseOptions{abort: true})) + assert.Equal(t, "old-b1", refs["b1"]) + assert.Equal(t, "b2", current) + assert.NoFileExists(t, filepath.Join(dir, rebaseStateFile)) +} diff --git a/cmd/trunk.go b/cmd/trunk.go index 7bd709b..d62db2f 100644 --- a/cmd/trunk.go +++ b/cmd/trunk.go @@ -9,7 +9,8 @@ import ( ) func TrunkCmd(cfg *config.Config) *cobra.Command { - return &cobra.Command{ + var printPath bool + cmd := &cobra.Command{ Use: "trunk", Short: "Check out the trunk branch of the stack", Long: `Check out the trunk branch of the current stack. @@ -20,18 +21,27 @@ You must be on a branch that is part of a stack.`, $ gh stack trunk`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runTrunk(cfg) + return runTrunkWithPath(cfg, printPath) }, } + cmd.Flags().BoolVar(&printPath, "print-path", false, "Print the trunk worktree path without switching a branch held elsewhere") + return cmd } func runTrunk(cfg *config.Config) error { - result, err := loadStack(cfg, "") + return runTrunkWithPath(cfg, false) +} + +func runTrunkWithPath(cfg *config.Config, printPath bool) error { + if printPath { + cfg = noninteractiveConfig(cfg) + } + result, err := loadNavigationStack(cfg, printPath) if err != nil { if errors.Is(err, errInterrupt) { return ErrSilent } - return ErrNotInStack + return stackLookupError(err) } s := result.Stack currentBranch := result.CurrentBranch @@ -39,9 +49,20 @@ func runTrunk(cfg *config.Config) error { if currentBranch == trunk { cfg.Printf("Already on trunk branch %s", trunk) + if printPath { + return checkoutWorktreeBranch(cfg, trunk, true) + } return nil } + owner, err := foreignWorktreePath(trunk) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" { + return checkoutWorktreeBranch(cfg, trunk, printPath) + } // Ensure trunk exists locally before checkout. exists, err := git.BranchExists(trunk) if err != nil { @@ -49,6 +70,11 @@ func runTrunk(cfg *config.Config) error { return ErrSilent } if !exists { + release, err := beginStackMutation(cfg, "trunk") + if err != nil { + return err + } + defer release() remote, err := pickRemote(cfg, currentBranch, "") if err != nil { if !errors.Is(err, errInterrupt) { @@ -62,9 +88,12 @@ func runTrunk(cfg *config.Config) error { } } - if err := git.CheckoutBranch(trunk); err != nil { + if err := checkoutWorktreeBranch(cfg, trunk, printPath); err != nil { return err } + if printPath { + return nil + } cfg.Successf("Switched to %s", trunk) return nil diff --git a/cmd/unstack.go b/cmd/unstack.go index e0251a0..db2324d 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -7,7 +7,6 @@ import ( "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/github" - "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/spf13/cobra" ) @@ -68,14 +67,26 @@ remain stacked, the stack is kept (and local tracking, if any, is unchanged).`, } func runUnstack(cfg *config.Config, opts *unstackOptions) error { + release, err := beginOptionalStackMutation(cfg, "unstack") + if err != nil { + return err + } + defer release() // A stack number targets a specific stack. It is unstacked directly on // GitHub by number (remote-first), so this works from anywhere in the // repository whether or not the stack is tracked locally. if opts.stackNumber > 0 { + if cfg.StackMutation == nil { + if !opts.local { + return runRemoteUnstack(cfg, opts.stackNumber) + } + cfg.Errorf("stack #%d is not tracked locally", opts.stackNumber) + return ErrNotInStack + } // --local must never contact GitHub, so it uses a strictly local lookup result, ok, err := lookupStackByNumber(cfg, opts.stackNumber, !opts.local) if err != nil { - return ErrNotInStack + return stackLookupError(err) } if !ok { // The stack number isn't tracked locally. @@ -94,7 +105,7 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { // No argument: operate on the active stack for the current branch. result, err := loadStack(cfg, "") if err != nil { - return ErrNotInStack + return stackLookupError(err) } return unstackTrackedStack(cfg, opts, result) } @@ -104,11 +115,6 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadStackResult) error { gitDir := result.GitDir - if err := modify.CheckStateGuard(gitDir); err != nil { - cfg.Errorf("%s", err) - return ErrModifyRecovery - } - sf := result.StackFile s := result.Stack @@ -158,7 +164,7 @@ func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadS } } if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) + return stackSaveError(cfg, err) } cfg.Successf("Stack removed from local tracking") diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 04817c6..87dfa23 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -16,6 +16,23 @@ import ( "github.com/stretchr/testify/require" ) +func TestUnstack_NumberWithoutLocalRepository(t *testing.T) { + defer mockRemoteOnlyGit()() + cfg, outR, errR := config.NewTestConfig() + unstacked := 0 + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(number int) (*github.RemoteStack, bool, error) { + unstacked = number + return nil, true, nil + }, + } + require.NoError(t, runUnstack(cfg, &unstackOptions{stackNumber: 7})) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Equal(t, 7, unstacked) + assert.NotContains(t, diagnostics, "not a git repository") +} + func writeTwoStacks(t *testing.T, dir string, s1, s2 stack.Stack) { t.Helper() sf := &stack.StackFile{ diff --git a/cmd/utils.go b/cmd/utils.go index ab6cdf0..ab24d90 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -215,10 +215,9 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { // result with a nil Stack when the branch is not tracked instead of reporting // an error. Other lookup failures are still reported and returned. func loadStackOptional(cfg *config.Config, branch string) (*loadStackResult, error) { - gitDir, err := git.GitDir() + gitDir, err := stackStateDir(cfg) if err != nil { - cfg.Errorf("not a git repository") - return nil, fmt.Errorf("not a git repository") + return nil, err } sf, err := stack.Load(gitDir) @@ -287,11 +286,14 @@ func reportBranchNotInStack(cfg *config.Config, branch string, branchFromArg boo // that must stay purely local (e.g. `--local`) pass false, and legacy stacks // whose number isn't recorded locally are reported as not tracked. func lookupStackByNumber(cfg *config.Config, number int, allowRemote bool) (result *loadStackResult, ok bool, err error) { - gitDir, err := git.GitDir() - if err != nil { + if _, err := git.CommonDir(); err != nil { // Not a git repository — nothing can be tracked locally. return nil, false, nil } + gitDir, err := stackStateDir(cfg) + if err != nil { + return nil, false, err + } sf, err := stack.Load(gitDir) if err != nil { @@ -404,9 +406,8 @@ func handleSaveError(cfg *config.Config, err error) error { // resolveStack finds the stack for the given branch, handling ambiguity when // a branch (typically a trunk) belongs to multiple stacks. If exactly one // stack matches, it is returned directly. If multiple stacks match, the user -// is prompted to select one and the working tree is switched to the top branch -// of the selected stack. Returns nil with no error if no stack contains the -// branch. +// is prompted to select one. Commands that need rewrite preflight and read-only +// callers leave checkout unchanged. Returns nil if no stack contains the branch. func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stack.Stack, error) { stacks := sf.FindAllStacksForBranch(branch) @@ -418,7 +419,7 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac } if !cfg.IsInteractive() { - return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one", branch) + return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one: %w", branch, ErrDisambiguate) } cfg.Warningf("Branch %q is the trunk of multiple stacks", branch) @@ -428,8 +429,14 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac options[i] = s.DisplayChain() } - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - selected, err := p.Select("Which stack would you like to use?", "", options) + var selected int + var err error + if cfg.SelectFn != nil { + selected, err = cfg.SelectFn("Which stack would you like to use?", "", options) + } else { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selected, err = p.Select("Which stack would you like to use?", "", options) + } if err != nil { if isInterruptError(err) { clearSelectPrompt(cfg, len(options)) @@ -439,17 +446,36 @@ func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stac return nil, fmt.Errorf("stack selection: %w", err) } + if selected < 0 || selected >= len(stacks) { + return nil, fmt.Errorf("invalid stack selection") + } s := stacks[selected] if len(s.Branches) == 0 { return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayChain()) } + // Selection must not mutate a checkout before rewrite ownership preflight. + if cfg.StackMutation == nil { + return s, nil + } + switch cfg.StackMutation.Kind { + case "rebase", "rebase-continue", "rebase-abort", "sync", "modify": + return s, nil + } // Switch to the top branch of the selected stack so future commands // resolve unambiguously. topBranch := s.Branches[len(s.Branches)-1].Branch if topBranch != branch { - if err := git.CheckoutBranch(topBranch); err != nil { + path, err := foreignWorktreePath(topBranch) + if err != nil { + return nil, err + } + if path != "" { + cfg.Infof("Selected stack; %s is checked out in %s", topBranch, path) + return s, nil + } + if err := checkoutWorktreeBranch(cfg, topBranch, false); err != nil { return nil, fmt.Errorf("failed to checkout branch %s: %w", topBranch, err) } cfg.Successf("Switched to %s", topBranch) @@ -773,6 +799,10 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string // in a stack. Call this after any operation that may have moved branch refs // (rebase, push, etc.). func updateBaseSHAs(s *stack.Stack) { + updateBaseSHAsWithTrunk(s, "") +} + +func updateBaseSHAsWithTrunk(s *stack.Stack, trunkSHA string) { // Collect all refs we need to resolve, then batch into one git call. var refs []string type refPair struct { @@ -806,13 +836,20 @@ func updateBaseSHAs(s *stack.Stack) { return } for _, p := range pairs { - if base, ok := shaMap[p.parent]; ok && canUpdateBase(base, p.branch, s.Branches[p.index].Base) { + base, ok := shaMap[p.parent] + if p.parent == s.Trunk.Branch && trunkSHA != "" { + base, ok = trunkSHA, true + } + if ok && canUpdateBase(base, p.branch, s.Branches[p.index].Base) { s.Branches[p.index].Base = base } if head, ok := shaMap[p.branch]; ok { s.Branches[p.index].Head = head } } + if trunkSHA != "" { + s.Trunk.Head = trunkSHA + } } // canUpdateBase reports whether parentSHA can replace a branch's recorded base. @@ -824,7 +861,15 @@ func canUpdateBase(parentSHA, branch, currentBase string) bool { return true } isAncestor, err := git.IsAncestor(parentSHA, branch) - return err == nil && isAncestor + if err != nil || !isAncestor { + return false + } + if valid, err := git.IsAncestor(currentBase, branch); err == nil && valid { + if older, err := git.IsAncestor(parentSHA, currentBase); err == nil && older { + return false + } + } + return true } // activeBranchNames returns the branch names for all non-merged branches in a stack. @@ -1625,6 +1670,9 @@ func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stac if err != nil { return res, nil } + if err := preflightSyncReconciliation(cfg, s, prs, remote); err != nil { + return res, err + } localActive, remoteActive := activeStackSequences(s, prs) @@ -1643,6 +1691,22 @@ func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stac } } +func preflightSyncReconciliation(cfg *config.Config, s *stack.Stack, prs []*github.PullRequest, remote string) error { + if cfg.StackMutation == nil || cfg.StackMutation.Kind != "sync" { + return nil + } + trunk, err := normalizeTrunkBranch(s.Trunk.Branch, remote) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + branches := append(s.BranchNames(), trunk) + for _, pr := range prs { + branches = append(branches, pr.HeadRefName) + } + return requireLocalBranches(cfg, branches) +} + // activeStackSequences returns the ordered active (non-merged) branch-name // sequences for the local stack and the fetched remote PRs. Merged state is // taken from the freshly fetched remote PRs (by branch name) when available so @@ -1837,6 +1901,11 @@ func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *sta cfg.Errorf("selection failed: %v", err) return remoteReconcileResult{}, ErrSilent } + if selected == 0 || selected == 1 { + if err := preflightSyncReconciliation(cfg, s, prs, remote); err != nil { + return remoteReconcileResult{}, err + } + } switch selected { case 0: @@ -1901,7 +1970,13 @@ func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stac // move them to the nearest surviving branch so they don't end up detached // from the stack. if target := nearestBranchAfterReplace(oldBranches, currentBranch, newStack); target != currentBranch { - if err := git.CheckoutBranch(target); err != nil { + path, err := foreignWorktreePath(target) + if err != nil { + return res, err + } + if path != "" { + cfg.Infof("Current checkout retained; surviving branch %s is in worktree %s", target, path) + } else if err := checkoutWorktreeBranch(cfg, target, false); err != nil { cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, target, err) } else { cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", target, currentBranch) diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 284beb0..8bc221a 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "strings" "testing" @@ -12,11 +14,851 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func commandOutput(t *testing.T, cfg *config.Config, outR, errR *os.File) (string, string) { + t.Helper() + require.NoError(t, cfg.Out.Close()) + require.NoError(t, cfg.Err.Close()) + defer outR.Close() + defer errR.Close() + out, err := io.ReadAll(outR) + require.NoError(t, err) + diagnostics, err := io.ReadAll(errR) + require.NoError(t, err) + return string(out), string(diagnostics) +} + +func requireWorktree(t *testing.T, parent git.Ops, path string) git.Ops { + t.Helper() + ops, err := parent.ForWorktree(path) + require.NoError(t, err) + require.NotNil(t, ops) + return ops +} + +func requireGitState(t *testing.T, query func() (bool, error)) bool { + t.Helper() + state, err := query() + require.NoError(t, err) + return state +} + +func mockRemoteOnlyGit() func() { + return git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return "", errors.New("not a git repository") }, + }) +} + +func TestResolveStack_ReadOnlySelectionDoesNotCheckout(t *testing.T) { + sf := &stack.StackFile{Stacks: []stack.Stack{ + {Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "one"}}}, + {Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "two"}}}, + }} + restore := git.SetOps(&git.MockOps{CheckoutBranchFn: func(string) error { + t.Fatal("read-only stack selection must not acquire a mutation or change checkout") + return nil + }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, options []string) (int, error) { + require.Len(t, options, 2) + return 1, nil + } + + selected, err := resolveStack(sf, "main", cfg) + + require.NoError(t, err) + assert.Same(t, &sf.Stacks[1], selected) + commandOutput(t, cfg, outR, errR) +} + +func TestStackMutation_NestedAndReadOnly(t *testing.T) { + common := t.TempDir() + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + + release, err := beginStackMutation(cfg, "add") + require.NoError(t, err) + defer release() + state := cfg.StackMutation + nested, err := beginStackMutation(cfg, "init") + require.NoError(t, err) + nested() + assert.Same(t, state, cfg.StackMutation) + dir, err := stackStateDir(cfg) + require.NoError(t, err) + assert.Equal(t, common, dir) + + lock, acquired, err := stack.TryLockOperation(common) + require.NoError(t, err) + if lock != nil { + defer lock.Unlock() + } + assert.False(t, acquired) + + reader := *cfg + reader.StackMutation = nil + dir, err = stackStateDir(&reader) + require.NoError(t, err, "a reader must not wait on the active operation lock") + assert.Equal(t, common, dir) + + release() + assert.Nil(t, cfg.StackMutation) + lock, acquired, err = stack.TryLockOperation(common) + require.NoError(t, err) + require.True(t, acquired) + lock.Unlock() + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) +} + +func TestStackMutation_RecoveryGuards(t *testing.T) { + tests := []struct { + name, file, data, kind string + want error + }{ + {"common rebase", "gh-stack-rebase-state", `{"worktrees":{}}`, "submit", ErrRebaseActive}, + {"common rebase continue", "gh-stack-rebase-state", `{"worktrees":{}}`, "rebase-continue", nil}, + {"common rebase abort", "gh-stack-rebase-state", `{"worktrees":{}}`, "rebase-abort", nil}, + {"modify applying", "gh-stack-modify-state", `{"worktrees":{},"phase":"applying"}`, "push", ErrModifyRecovery}, + {"modify conflict", "gh-stack-modify-state", `{"worktrees":{},"phase":"conflict"}`, "link", ErrModifyRecovery}, + {"modify recovery", "gh-stack-modify-state", `{"worktrees":{},"phase":"conflict"}`, "modify-continue", nil}, + {"pending does not block", "gh-stack-modify-state", `{"worktrees":{},"phase":"pending_submit"}`, "init", nil}, + {"corrupt rebase", "gh-stack-rebase-state", `{`, "rebase-abort", ErrRebaseActive}, + {"corrupt modify", "gh-stack-modify-state", `{`, "submit", ErrModifyRecovery}, + {"invalid pending snapshot", "gh-stack-modify-state", `{"phase":"pending_submit","snapshot":"invalid"}`, "push", ErrModifyRecovery}, + {"unknown modify phase", "gh-stack-modify-state", `{"phase":"unknown"}`, "push", ErrModifyRecovery}, + {"null rebase", "gh-stack-rebase-state", `null`, "push", ErrRebaseActive}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + common, local := t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(common, tt.file), []byte(tt.data), 0600)) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, tt.kind) + if tt.want != nil { + require.ErrorIs(t, err, tt.want) + assert.Nil(t, cfg.StackMutation) + } else { + require.NoError(t, err) + assert.Equal(t, common, cfg.StackMutation.StateDir) + release() + } + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + }) + } +} + +func TestStackMutation_LegacyRecoveryUsesOriginalCatalog(t *testing.T) { + for _, original := range []bool{true, false} { + t.Run(fmt.Sprintf("original=%t", original), func(t *testing.T) { + common := t.TempDir() + legacy := filepath.Join(common, "worktrees", "original") + require.NoError(t, os.MkdirAll(legacy, 0700)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "shared"}}}) + writeStackFile(t, legacy, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "original"}}}) + require.NoError(t, os.WriteFile(filepath.Join(legacy, rebaseStateFile), []byte(`{"originalBranch":"original"}`), 0600)) + local := common + if original { + local = legacy + } + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, "rebase-abort") + if original { + require.NoError(t, err) + defer release() + dir, err := stackStateDir(cfg) + require.NoError(t, err) + assert.Equal(t, legacy, dir) + sf, err := stack.Load(dir) + require.NoError(t, err) + assert.Equal(t, []string{"original"}, sf.Stacks[0].BranchNames()) + } else { + require.ErrorIs(t, err, ErrRebaseActive) + } + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + if !original { + assert.Contains(t, diagnostics, "original worktree") + assert.Contains(t, diagnostics, legacy) + } + }) + } +} + +func TestStackMutation_UpgradedPrivateJournalUsesOriginalCatalog(t *testing.T) { + for _, operation := range []string{"rebase", "modify"} { + for _, action := range []string{"continue", "abort"} { + for _, original := range []bool{true, false} { + t.Run(fmt.Sprintf("%s-%s/original=%t", operation, action, original), func(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "original") + require.NoError(t, os.MkdirAll(private, 0700)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "shared"}}}) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "original"}}}) + journal := filepath.Join(private, "gh-stack-"+operation+"-state") + require.NoError(t, os.WriteFile(journal, []byte(`{"phase":"conflict","worktrees":{}}`), 0600)) + local := common + if original { + local = private + } + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, operation+"-"+action) + if original { + require.NoError(t, err) + defer release() + dir, err := stackStateDir(cfg) + require.NoError(t, err) + assert.Equal(t, private, dir) + sf, err := stack.Load(dir) + require.NoError(t, err) + assert.Equal(t, []string{"original"}, sf.Stacks[0].BranchNames()) + } else if operation == "rebase" { + assert.ErrorIs(t, err, ErrRebaseActive) + } else { + assert.ErrorIs(t, err, ErrModifyRecovery) + } + shared, err := stack.Load(common) + require.NoError(t, err) + require.Len(t, shared.Stacks, 1) + assert.Equal(t, []string{"shared"}, shared.Stacks[0].BranchNames()) + assert.FileExists(t, filepath.Join(private, "gh-stack")) + assert.FileExists(t, journal) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + }) + } + } + } +} + +func TestStackMutation_NullWorktreeContextIsLegacy(t *testing.T) { + for _, operation := range []string{"rebase", "modify"} { + for _, original := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/original=%t", operation, original), func(t *testing.T) { + common, local := t.TempDir(), t.TempDir() + if original { + local = common + } + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "original"}}}) + journal := filepath.Join(common, "gh-stack-"+operation+"-state") + data := []byte(`{"phase":"conflict","worktrees":null}`) + require.NoError(t, os.WriteFile(journal, data, 0600)) + legacyCatalogs, err := stack.HasLegacyState(common) + require.NoError(t, err) + assert.False(t, legacyCatalogs, "journal detection is independent of catalog migration") + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, operation+"-continue") + if original { + require.NoError(t, err) + assert.Equal(t, common, cfg.StackMutation.StateDir) + release() + } else if operation == "rebase" { + assert.ErrorIs(t, err, ErrRebaseActive) + } else { + assert.ErrorIs(t, err, ErrModifyRecovery) + } + after, err := os.ReadFile(journal) + require.NoError(t, err) + assert.Equal(t, data, after) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + if !original { + assert.Contains(t, diagnostics, "original worktree") + } + }) + } + } +} + +func TestStackMutation_CommonRecoveryDefersLegacyMigration(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "other") + require.NoError(t, os.MkdirAll(private, 0700)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "original"}}}) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "other"}}}) + require.NoError(t, os.WriteFile(filepath.Join(common, rebaseStateFile), []byte(`{"phase":"conflict","worktrees":{}}`), 0600)) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, "rebase-continue") + require.NoError(t, err) + defer release() + dir, err := stackStateDir(cfg) + require.NoError(t, err) + assert.Equal(t, common, dir) + sf, err := stack.Load(common) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"original"}, sf.Stacks[0].BranchNames()) + assert.FileExists(t, filepath.Join(private, "gh-stack")) + commandOutput(t, cfg, outR, errR) +} + +func TestStackMutation_PrivateAndCommonRecoveryAreAmbiguous(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "original") + require.NoError(t, os.MkdirAll(private, 0700)) + for _, dir := range []string{common, private} { + require.NoError(t, os.WriteFile(filepath.Join(dir, rebaseStateFile), []byte(`{"phase":"conflict","worktrees":{}}`), 0600)) + } + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return private, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, "rebase-continue") + assert.ErrorIs(t, err, ErrRebaseActive) + assert.Nil(t, release) + assert.Nil(t, cfg.StackMutation) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, "multiple rebase recovery journals") + assert.Contains(t, diagnostics, common) + assert.Contains(t, diagnostics, private) +} + +func TestStackMutation_MigrationErrorsLeaveCatalogsIntact(t *testing.T) { + common := t.TempDir() + legacy := filepath.Join(common, "worktrees", "other") + require.NoError(t, os.MkdirAll(legacy, 0700)) + writeStackFile(t, common, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "same", Base: "one"}}}) + writeStackFile(t, legacy, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "same", Base: "two"}}}) + before, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + _, err = stackStateDir(cfg) + require.Error(t, err) + var migrationErr *stack.MigrationConflictError + require.ErrorAs(t, err, &migrationErr) + assert.NotEmpty(t, migrationErr.Sources) + assert.NotEmpty(t, migrationErr.Reason) + release, err := beginStackMutation(cfg, "push") + require.Error(t, err) + migrationErr = nil + require.ErrorAs(t, err, &migrationErr) + assert.Nil(t, release) + after, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.FileExists(t, filepath.Join(legacy, "gh-stack")) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, "migrat") +} + +func TestStackStateDir_PreservesMigrationBlockedError(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "original") + require.NoError(t, os.MkdirAll(private, 0700)) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "original"}}}) + journal := filepath.Join(private, rebaseStateFile) + require.NoError(t, os.WriteFile(journal, []byte(`{"originalBranch":"original"}`), 0600)) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + _, err := stackStateDir(cfg) + assert.ErrorIs(t, err, ErrSilent) + var blocked *stack.MigrationBlockedError + require.ErrorAs(t, err, &blocked) + assert.Contains(t, blocked.RecoveryPaths, journal) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, fmt.Sprintf("%q", journal)) +} + +func TestStackStateError_PreservesWindowsRecoveryPath(t *testing.T) { + journal := `C:\Users\Example Worktree\.git\worktrees\original\gh-stack-rebase-state` + cause := &stack.MigrationBlockedError{RecoveryPaths: []string{journal}} + cfg, outR, errR := config.NewTestConfig() + + err := stackStateError(cfg, "migrating stack state", cause) + + assert.ErrorIs(t, err, ErrSilent) + var blocked *stack.MigrationBlockedError + require.ErrorAs(t, err, &blocked) + assert.Same(t, cause, blocked) + assert.Equal(t, []string{journal}, blocked.RecoveryPaths) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, `"C:\\Users\\Example Worktree\\.git\\worktrees\\original\\gh-stack-rebase-state"`) +} + +func TestStackStateHelpers_PreserveLockError(t *testing.T) { + for _, operationLock := range []bool{true, false} { + t.Run(fmt.Sprintf("operationLock=%t", operationLock), func(t *testing.T) { + common := t.TempDir() + private := filepath.Join(common, "worktrees", "other") + require.NoError(t, os.MkdirAll(private, 0700)) + writeStackFile(t, private, stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "other"}}}) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + lockFn := stack.Lock + if operationLock { + lockFn = stack.LockOperation + } + lock, err := lockFn(common) + require.NoError(t, err) + defer lock.Unlock() + timeout := stack.LockTimeout + stack.LockTimeout = 0 + defer func() { stack.LockTimeout = timeout }() + cfg, outR, errR := config.NewTestConfig() + _, err = stackStateDir(cfg) + assert.ErrorIs(t, err, ErrLockFailed) + var lockErr *stack.LockError + require.ErrorAs(t, err, &lockErr) + release, err := beginStackMutation(cfg, "push") + assert.ErrorIs(t, err, ErrLockFailed) + assert.Nil(t, release) + lockErr = nil + require.ErrorAs(t, err, &lockErr) + assert.Nil(t, cfg.StackMutation) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + }) + } +} + +func TestStackSaveError_PreservesTypedCause(t *testing.T) { + for _, tt := range []struct { + name string + cause error + exit error + }{ + {"lock", &stack.LockError{Err: assert.AnError}, ErrLockFailed}, + {"stale", &stack.StaleError{Err: assert.AnError}, ErrLockFailed}, + {"migration conflict", &stack.MigrationConflictError{Sources: []string{"original"}, Reason: "different definitions"}, ErrSilent}, + {"migration blocked", &stack.MigrationBlockedError{RecoveryPaths: []string{"journal"}}, ErrSilent}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + err := stackSaveError(cfg, tt.cause) + assert.ErrorIs(t, err, tt.exit) + assert.ErrorIs(t, err, tt.cause) + var exitErr *ExitError + require.ErrorAs(t, err, &exitErr) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.NotEmpty(t, diagnostics) + }) + } +} + +func TestStackLookupError_CommandCallerMapping(t *testing.T) { + storageErr := &stack.LockError{Err: assert.AnError} + for _, tt := range []struct { + name string + input error + want error + retain bool + }{ + {"typed exit", ErrRebaseActive, ErrRebaseActive, true}, + {"wrapped typed exit", errors.Join(ErrLockFailed, storageErr), ErrLockFailed, true}, + {"interrupt", errInterrupt, ErrSilent, false}, + {"wrapped interrupt", fmt.Errorf("selection: %w", errInterrupt), ErrSilent, false}, + {"untyped lookup failure", assert.AnError, ErrNotInStack, false}, + } { + t.Run(tt.name, func(t *testing.T) { + mapped := stackLookupError(tt.input) + assert.ErrorIs(t, mapped, tt.want) + if tt.retain { + assert.Same(t, tt.input, mapped) + } + }) + } +} + +func TestCheckoutWorktreeBranch_OutputContract(t *testing.T) { + tests := []struct { + name, current string + foreign, pathMode bool + checkoutError bool + rootError bool + wantError bool + }{ + {"foreign path", "b1", true, true, false, false, false}, + {"foreign normal", "b1", true, false, false, false, true}, + {"unoccupied path", "b1", false, true, false, false, false}, + {"current path", "b2", false, true, false, false, false}, + {"failed checkout", "b1", false, true, true, false, true}, + {"failed root", "b1", false, true, false, true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + common, root := t.TempDir(), t.TempDir() + owner := filepath.Join(t.TempDir(), "owner's worktree") + var checkedOut []string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + CurrentBranchFn: func() (string, error) { return tt.current, nil }, + RootDirFn: func() (string, error) { + if tt.rootError { + return "", assert.AnError + } + return root, nil + }, + WorktreesFn: func() ([]git.Worktree, error) { + if tt.foreign { + return []git.Worktree{{Path: owner, Branch: "b2"}}, nil + } + return nil, nil + }, + CheckoutBranchFn: func(branch string) error { + checkedOut = append(checkedOut, branch) + if tt.checkoutError { + return assert.AnError + } + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + err := checkoutWorktreeBranch(cfg, "b2", tt.pathMode) + out, diagnostics := commandOutput(t, cfg, outR, errR) + if tt.wantError { + require.Error(t, err) + assert.Empty(t, out) + } else { + require.NoError(t, err) + want := root + if tt.foreign { + want = owner + } + assert.Equal(t, want+"\n", out) + } + if tt.foreign || tt.current == "b2" || tt.rootError { + assert.Empty(t, checkedOut) + } else { + assert.Equal(t, []string{"b2"}, checkedOut) + } + if tt.foreign && !tt.pathMode { + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, diagnostics, owner) + assert.Contains(t, diagnostics, "cd -- '") + assert.Contains(t, diagnostics, "'\\''") + assert.NotContains(t, diagnostics, "Switched") + } + }) + } +} + +func TestCheckoutWorktreeBranch_ForeignLookupDuringPausedOperation(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(common, rebaseStateFile), []byte(`{"worktrees":{}}`), 0600)) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + WorktreesFn: func() ([]git.Worktree, error) { return []git.Worktree{{Path: owner, Branch: "b1"}}, nil }, + CheckoutBranchFn: func(string) error { + t.Fatal("foreign lookup must not change a checkout") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + lock, err := stack.LockOperation(common) + require.NoError(t, err) + require.NoError(t, checkoutWorktreeBranch(cfg, "b1", true)) + lock.Unlock() + out, _ := commandOutput(t, cfg, outR, errR) + assert.Equal(t, owner+"\n", out) + + cfg, outR, errR = config.NewTestConfig() + assert.ErrorIs(t, checkoutWorktreeBranch(cfg, "b2", true), ErrRebaseActive) + out, _ = commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) +} + +func TestCheckoutWorktreeBranch_OwnershipErrors(t *testing.T) { + for _, reason := range []string{"list", "duplicate", "prunable", "different repository", "relative path", "factory"} { + t.Run(reason, func(t *testing.T) { + common, root, owner := t.TempDir(), t.TempDir(), t.TempDir() + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + WorktreesFn: func() ([]git.Worktree, error) { + switch reason { + case "list": + return nil, assert.AnError + case "duplicate": + return []git.Worktree{{Path: owner, Branch: "b1"}, {Path: root, Branch: "b1"}}, nil + case "prunable": + return []git.Worktree{{Path: owner, Branch: "b1", Prunable: true}}, nil + case "relative path": + return []git.Worktree{{Path: "relative", Branch: "b1"}}, nil + default: + return []git.Worktree{{Path: owner, Branch: "b1"}}, nil + } + }, + CheckoutBranchFn: func(string) error { + t.Fatal("ownership errors must not fall back to checkout") + return nil + }, + } + if reason == "different repository" { + mock.ForWorktreeFn = func(string) (git.Ops, error) { + return &git.MockOps{CommonDirFn: func() (string, error) { return root, nil }}, nil + } + } + if reason == "factory" { + mock.ForWorktreeFn = func(string) (git.Ops, error) { return nil, assert.AnError } + } + restore := git.SetOps(mock) + defer restore() + cfg, outR, errR := config.NewTestConfig() + require.Error(t, checkoutWorktreeBranch(cfg, "b1", true)) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.NotEmpty(t, diagnostics) + if reason == "factory" { + assert.Contains(t, diagnostics, assert.AnError.Error()) + } + }) + } +} + +func TestCheckoutWorktreeBranch_StateLookupErrorsBeforeSwitch(t *testing.T) { + for _, query := range []string{"rebase", "cherry-pick"} { + t.Run(query, func(t *testing.T) { + common, root := t.TempDir(), t.TempDir() + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return root, nil }, + CheckoutBranchFn: func(string) error { + t.Fatal("state lookup failure must not change checkout") + return nil + }, + } + fail := func() (bool, error) { return false, assert.AnError } + if query == "rebase" { + mock.IsRebaseInProgressFn = fail + } else { + mock.IsCherryPickInProgressFn = fail + } + restore := git.SetOps(mock) + defer restore() + cfg, outR, errR := config.NewTestConfig() + + require.ErrorIs(t, checkoutWorktreeBranch(cfg, "branch", true), ErrSilent) + + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, assert.AnError.Error()) + assert.NoFileExists(t, filepath.Join(common, "gh-stack")) + }) + } +} + +func TestReadStackJournals_AtomicPublication(t *testing.T) { + for _, kind := range []string{"rebase", "modify"} { + t.Run(kind, func(t *testing.T) { + common, origin := t.TempDir(), t.TempDir() + path := filepath.Join(common, "gh-stack-"+kind+"-state") + cfg, outR, errR := config.NewTestConfig() + for _, phase := range []string{"applying", "conflict"} { + data := []byte(fmt.Sprintf(`{"phase":%q,"worktrees":{"origin":{"path":%q}}}`, phase, origin)) + require.NoError(t, stack.WriteAtomic(path, data)) + + journals, err := readStackJournals(cfg, common, common) + + require.NoError(t, err) + require.Len(t, journals, 1) + assert.Equal(t, stackJournal{ + dir: common, path: path, kind: kind, phase: phase, origin: origin, + }, journals[0]) + } + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Empty(t, diagnostics) + }) + } +} + +func TestStackMutation_ReadErrorsFailClosed(t *testing.T) { + common := t.TempDir() + require.NoError(t, os.Mkdir(modify.StatePath(common), 0700)) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, "submit") + assert.ErrorIs(t, err, ErrModifyRecovery) + assert.Nil(t, release) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) +} + +func TestStackMutation_ReportsJournalOrigin(t *testing.T) { + common, origin := t.TempDir(), t.TempDir() + journal := fmt.Sprintf(`{"worktrees":{"origin":{"path":%q,"id":"worktrees/origin"}}}`, origin) + require.NoError(t, os.WriteFile(filepath.Join(common, rebaseStateFile), []byte(journal), 0600)) + restore := git.SetOps(&git.MockOps{GitDirFn: func() (string, error) { return common, nil }}) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, "push") + assert.ErrorIs(t, err, ErrRebaseActive) + assert.Nil(t, release) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + assert.Contains(t, diagnostics, origin) + assert.Contains(t, diagnostics, "gh stack rebase --continue") +} + +func TestStackMutation_RebaseJournalPhasesBlockOtherMutations(t *testing.T) { + for _, phase := range []string{"applying", "conflict", "complete", "restoring"} { + t.Run(phase, func(t *testing.T) { + common, local := t.TempDir(), t.TempDir() + data := fmt.Sprintf(`{"phase":%q,"worktrees":{},"originalBranch":"b1"}`, phase) + require.NoError(t, os.WriteFile(filepath.Join(common, rebaseStateFile), []byte(data), 0600)) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + }) + defer restore() + for _, kind := range []string{"init", "add", "checkout", "push", "submit", "link", "merge", "unstack", "rebase", "sync", "modify", "modify-abort"} { + t.Run(kind, func(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, kind) + assert.ErrorIs(t, err, ErrRebaseActive) + assert.Nil(t, release) + assert.Nil(t, cfg.StackMutation) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + }) + } + for _, kind := range []string{"rebase-continue", "rebase-abort"} { + t.Run(kind, func(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + release, err := beginStackMutation(cfg, kind) + require.NoError(t, err) + release() + commandOutput(t, cfg, outR, errR) + }) + } + }) + } +} + +func TestStackMutatingCommands_RecoveryBeforeSideEffects(t *testing.T) { + commands := []struct { + name string + run func(*config.Config) error + }{ + {"init", func(cfg *config.Config) error { + return runInit(cfg, &initOptions{base: "main", branches: []string{"new"}}) + }}, + {"add", func(cfg *config.Config) error { + return runAdd(cfg, &addOptions{stageAll: true, message: "commit"}, []string{"new"}) + }}, + {"push", func(cfg *config.Config) error { return runPush(cfg, &pushOptions{}) }}, + {"submit", func(cfg *config.Config) error { return runSubmit(cfg, &submitOptions{auto: true}) }}, + {"link", func(cfg *config.Config) error { return runLink(cfg, &linkOptions{}, []string{"1", "2"}) }}, + {"merge", func(cfg *config.Config) error { return runMerge(cfg, &mergeOptions{}, []string{"7"}) }}, + {"unstack", func(cfg *config.Config) error { return runUnstack(cfg, &unstackOptions{stackNumber: 7}) }}, + } + for _, command := range commands { + t.Run(command.name, func(t *testing.T) { + for _, journal := range []struct { + file, data string + want error + }{ + {rebaseStateFile, `{"worktrees":{}}`, ErrRebaseActive}, + {"gh-stack-modify-state", `{"worktrees":{},"phase":"applying"}`, ErrModifyRecovery}, + {"gh-stack-modify-state", `{"worktrees":{},"phase":"conflict"}`, ErrModifyRecovery}, + } { + t.Run(journal.file+journal.data, func(t *testing.T) { + common, local := t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(common, journal.file), []byte(journal.data), 0600)) + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + PushFn: func(string, []string, bool, bool) error { + t.Fatal("recovery guard must run before any push") + return nil + }, + StageAllFn: func() error { + t.Fatal("recovery guard must run before staging") + return nil + }, + CreateBranchFn: func(string, string) error { + t.Fatal("recovery guard must run before branch creation") + return nil + }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + t.Fatal("guard must run before remote operations") + return nil, nil + }, + } + assert.ErrorIs(t, command.run(cfg), journal.want) + out, _ := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + }) + } + }) + } +} + +func TestStackStateDir_VersionAndCommonDirFailures(t *testing.T) { + for _, oldVersion := range []bool{true, false} { + t.Run(fmt.Sprintf("oldVersion=%t", oldVersion), func(t *testing.T) { + common := t.TempDir() + mock := &git.MockOps{GitDirFn: func() (string, error) { return common, nil }} + if oldVersion { + mock.CheckVersionFn = func() error { return fmt.Errorf("Git 2.36 or newer is required; upgrade Git") } + } else { + mock.CommonDirFn = func() (string, error) { return "", assert.AnError } + } + restore := git.SetOps(mock) + defer restore() + cfg, outR, errR := config.NewTestConfig() + release, err := beginOptionalStackMutation(cfg, "link") + require.Error(t, err) + assert.Nil(t, release) + assert.NoFileExists(t, filepath.Join(common, "gh-stack-operation.lock")) + out, diagnostics := commandOutput(t, cfg, outR, errR) + assert.Empty(t, out) + if oldVersion { + assert.Contains(t, diagnostics, "upgrade Git") + } + }) + } +} + func TestIsInterruptError_DirectMatch(t *testing.T) { if !isInterruptError(terminal.InterruptErr) { t.Error("expected true for terminal.InterruptErr") diff --git a/cmd/view.go b/cmd/view.go index 057de60..27b2c4b 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -68,7 +69,7 @@ func runView(cfg *config.Config, opts *viewOptions) error { result, err := loadStack(cfg, "") if err != nil { - return ErrNotInStack + return stackLookupError(err) } gitDir := result.GitDir sf := result.StackFile @@ -101,10 +102,9 @@ func runView(cfg *config.Config, opts *viewOptions) error { // It resolves the stack directly and returns typed exit codes when the // branch is not part of any stack or belongs to multiple stacks. func runViewJSON(cfg *config.Config) error { - gitDir, err := git.GitDir() + gitDir, err := stackStateDir(cfg) if err != nil { - cfg.Errorf("not a git repository") - return ErrNotInStack + return err } sf, err := stack.Load(gitDir) @@ -329,8 +329,13 @@ func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string, prDet // Checkout branch if user requested it if m, ok := finalModel.(stackview.Model); ok { if branch := m.CheckoutBranch(); branch != "" { - if err := git.CheckoutBranch(branch); err != nil { + if err := checkoutWorktreeBranch(cfg, branch, false); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } cfg.Errorf("failed to checkout %s: %v", branch, err) + return ErrSilent } else { cfg.Successf("Switched to %s", branch) } diff --git a/cmd/view_test.go b/cmd/view_test.go index a7a6e0b..ff64050 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -551,3 +551,38 @@ func TestRunViewJSON_SingleStack(t *testing.T) { assert.Equal(t, "feat/01", got.Branches[0].Name) assert.True(t, got.Branches[0].IsCurrent) } + +func TestRunViewJSON_SharedCatalogDuringMutation(t *testing.T) { + common, local := t.TempDir(), t.TempDir() + writeStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}}}, + }) + before, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + lock, err := stack.LockOperation(common) + require.NoError(t, err) + defer lock.Unlock() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return local, nil }, + CommonDirFn: func() (string, error) { return common, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(int) (*github.PullRequest, error) { + return &github.PullRequest{Number: 1, State: "OPEN", URL: "https://github.com/o/r/pull/1"}, nil + }, + } + require.NoError(t, runViewJSON(cfg)) + out, _ := commandOutput(t, cfg, outR, errR) + var result viewJSONOutput + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "b1", result.CurrentBranch) + require.Len(t, result.Branches, 1) + after, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after, "read-only metadata refresh cannot overwrite a mutation's catalog") + assert.NoFileExists(t, filepath.Join(local, "gh-stack")) +} diff --git a/cmd/worktree_utils.go b/cmd/worktree_utils.go new file mode 100644 index 0000000..9c47e69 --- /dev/null +++ b/cmd/worktree_utils.go @@ -0,0 +1,494 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/modify" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" +) + +func commonStackDir(cfg *config.Config) (string, error) { + if err := git.CheckVersion(); err != nil { + cfg.Errorf("%s", err) + return "", ErrSilent + } + dir, err := git.CommonDir() + if err != nil { + cfg.Errorf("not a git repository: %s", err) + return "", ErrNotInStack + } + if !filepath.IsAbs(dir) { + cfg.Errorf("could not determine an absolute common Git directory: %q", dir) + return "", ErrSilent + } + return dir, nil +} + +// Readers only take the operation lock when legacy state needs migration. +func stackStateDir(cfg *config.Config) (string, error) { + if cfg.StackMutation != nil { + return cfg.StackMutation.StateDir, nil + } + commonDir, err := commonStackDir(cfg) + if err != nil { + return "", err + } + legacy, err := stack.HasLegacyState(commonDir) + if err != nil { + return "", stackStateError(cfg, "checking legacy stack state", err) + } + if !legacy { + return commonDir, nil + } + lock, err := stack.LockOperation(commonDir) + if err != nil { + return "", stackStateError(cfg, "acquiring stack operation lock", err) + } + defer lock.Unlock() + if err := stack.MigrateLegacyState(commonDir); err != nil { + return "", stackStateError(cfg, "migrating stack state", err) + } + return commonDir, nil +} + +// Nested command calls share the outer command's lock and catalog selection. +func beginStackMutation(cfg *config.Config, kind string) (func(), error) { + if cfg.StackMutation != nil { + return func() {}, nil + } + commonDir, err := commonStackDir(cfg) + if err != nil { + return nil, err + } + lock, err := stack.LockOperation(commonDir) + if err != nil { + return nil, stackStateError(cfg, "acquiring stack operation lock", err) + } + stateDir, err := mutationStateDir(cfg, commonDir, kind) + if err != nil { + lock.Unlock() + return nil, err + } + cfg.StackMutation = &config.StackMutationContext{CommonDir: commonDir, StateDir: stateDir, Kind: kind} + released := false + return func() { + if !released { + released = true + cfg.StackMutation = nil + lock.Unlock() + } + }, nil +} + +func stackStateError(cfg *config.Config, action string, err error) error { + var lockErr *stack.LockError + if errors.As(err, &lockErr) { + return stackSaveError(cfg, err) + } + cfg.Errorf("%s: %s", action, err) + return errors.Join(ErrSilent, err) +} + +func stackSaveError(cfg *config.Config, err error) error { + mapped := handleSaveError(cfg, err) + if errors.Is(mapped, err) { + return mapped + } + return errors.Join(mapped, err) +} + +// API-only commands still coordinate when invoked inside a local repository. +func beginOptionalStackMutation(cfg *config.Config, kind string) (func(), error) { + if cfg.StackMutation == nil { + if _, err := git.GitDir(); err != nil { + return func() {}, nil + } + } + return beginStackMutation(cfg, kind) +} + +type stackJournal struct { + dir string + path string + kind string + phase string + origin string + legacy bool +} + +func readStackJournals(cfg *config.Config, commonDir, localDir string) ([]stackJournal, error) { + dirs := []string{commonDir} + if !worktree.SamePath(commonDir, localDir) { + dirs = append(dirs, localDir) + } + entries, err := os.ReadDir(filepath.Join(commonDir, "worktrees")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + cfg.Errorf("reading worktree recovery directories: %s", err) + return nil, ErrSilent + } + for _, entry := range entries { + dir := filepath.Join(commonDir, "worktrees", entry.Name()) + if entry.IsDir() && !worktree.SamePath(dir, localDir) { + dirs = append(dirs, dir) + } + } + var journals []stackJournal + for _, dir := range dirs { + for _, kind := range []string{"rebase", "modify"} { + path := filepath.Join(dir, "gh-stack-"+kind+"-state") + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err == nil && !info.Mode().IsRegular() { + err = fmt.Errorf("recovery state is not a regular file") + } + exitErr := ErrRebaseActive + if kind == "modify" { + exitErr = ErrModifyRecovery + } + journal := stackJournal{dir: dir, path: path, kind: kind} + if err == nil { + var data []byte + data, err = stack.ReadStateFile(path) + if err == nil { + if kind == "rebase" { + var state *rebaseState + err = json.Unmarshal(data, &state) + if err == nil && state == nil { + err = fmt.Errorf("invalid rebase recovery record") + } + if err == nil { + journal.phase, journal.legacy = state.Phase, state.Worktrees == nil + if state.Worktrees != nil { + journal.origin = state.Worktrees.Origin.Path + } + } + } else { + var state *modify.StateFile + err = json.Unmarshal(data, &state) + if err == nil && (state == nil || + (state.Phase != modify.PhaseApplying && state.Phase != modify.PhaseConflict && state.Phase != modify.PhasePendingSubmit)) { + err = fmt.Errorf("invalid modify recovery record") + } + if err == nil { + journal.phase, journal.legacy = state.Phase, state.Worktrees == nil + if state.Worktrees != nil { + journal.origin = state.Worktrees.Origin.Path + } + } + } + } + } + if err != nil { + cfg.Errorf("reading recovery state %s: %s", path, err) + return nil, exitErr + } + // Upgrading a legacy journal does not move its private catalog. + journal.legacy = journal.legacy || !worktree.SamePath(dir, commonDir) + journals = append(journals, journal) + } + } + return journals, nil +} + +func mutationStateDir(cfg *config.Config, commonDir, kind string) (string, error) { + localDir, err := git.GitDir() + if err != nil { + cfg.Errorf("finding worktree Git directory: %s", err) + return "", ErrNotInStack + } + if !filepath.IsAbs(localDir) { + cfg.Errorf("could not determine an absolute worktree Git directory: %q", localDir) + return "", ErrSilent + } + journals, err := readStackJournals(cfg, commonDir, localDir) + if err != nil { + return "", err + } + legacyRecovery := false + for _, journal := range journals { + if journal.legacy && worktree.SamePath(journal.dir, localDir) && journalAllowsRecovery(journal, kind) { + legacyRecovery = true + } + } + recoveryDir := "" + for _, journal := range journals { + // Independent old-version sessions must be recovered one at a time, + // each against its own catalog, never against a merged stack index. + if legacyRecovery && journal.legacy && !worktree.SamePath(journal.dir, localDir) { + continue + } + if journal.kind == "modify" && journal.phase == modify.PhasePendingSubmit { + continue + } + if journalAllowsRecovery(journal, kind) && + (!journal.legacy || worktree.SamePath(journal.dir, localDir)) { + if recoveryDir != "" && !worktree.SamePath(recoveryDir, journal.dir) { + cfg.Errorf("multiple %s recovery journals found in %s and %s; preserve both catalogs and resolve the conflicting sessions before continuing", journal.kind, recoveryDir, journal.dir) + if journal.kind == "rebase" { + return "", ErrRebaseActive + } + return "", ErrModifyRecovery + } + recoveryDir = journal.dir + continue + } + if journal.legacy { + cfg.Errorf("a legacy %s session needs recovery in its original worktree (Git directory %s)", journal.kind, journal.dir) + } else { + cfg.Errorf("a %s operation is already in progress (%s)", journal.kind, journal.path) + if journal.origin != "" { + cfg.Printf("Operation started in worktree %s", journal.origin) + } + } + cfg.Printf("Run `gh stack %s --continue` or `gh stack %s --abort` before another mutation", journal.kind, journal.kind) + if journal.kind == "rebase" { + return "", ErrRebaseActive + } + return "", ErrModifyRecovery + } + if legacyRecovery { + return localDir, nil + } + if recoveryDir != "" { + // Recovery stays with its saved catalog, even if a legacy main-worktree + // journal gained a Context while other private catalogs still exist. + return recoveryDir, nil + } + if err := stack.MigrateLegacyState(commonDir); err != nil { + return "", stackStateError(cfg, "migrating stack state", err) + } + return commonDir, nil +} + +func journalAllowsRecovery(journal stackJournal, kind string) bool { + if journal.kind == "modify" && journal.phase == modify.PhasePendingSubmit { + return kind == "submit" || kind == "modify-abort" + } + return kind == journal.kind+"-continue" || kind == journal.kind+"-abort" +} + +func noninteractiveConfig(cfg *config.Config) *config.Config { + copy := *cfg + copy.NonInteractive = true + return © +} + +func stackLookupError(err error) error { + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } + if errors.Is(err, errInterrupt) { + return ErrSilent + } + return ErrNotInStack +} + +func loadNavigationStack(cfg *config.Config, printPath bool) (*loadStackResult, error) { + if !printPath { + return loadStack(cfg, "") + } + dir, err := stackStateDir(cfg) + if err != nil { + return nil, err + } + sf, err := stack.Load(dir) + if err != nil { + cfg.Errorf("loading stack state: %s", err) + return nil, ErrNotInStack + } + current, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("finding current branch: %s", err) + return nil, ErrNotInStack + } + stacks := sf.FindAllStacksForBranch(current) + if len(stacks) == 0 { + reportBranchNotInStack(cfg, current, false) + return nil, ErrNotInStack + } + if len(stacks) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", current) + return nil, ErrDisambiguate + } + return &loadStackResult{GitDir: dir, StackFile: sf, Stack: stacks[0], CurrentBranch: current}, nil +} + +func foreignWorktreePath(target string) (string, error) { + trees, err := git.Worktrees() + if err != nil { + return "", fmt.Errorf("listing worktrees: %w", err) + } + var owner string + for _, tree := range trees { + if tree.Bare || tree.Branch != target { + continue + } + if tree.Path == "" || !filepath.IsAbs(tree.Path) { + return "", fmt.Errorf("branch %q has an invalid worktree path %q", target, tree.Path) + } + if owner != "" && !worktree.SamePath(owner, tree.Path) { + return "", fmt.Errorf("branch %q is checked out in multiple worktrees (%s and %s)", target, owner, tree.Path) + } + if tree.Prunable { + return "", fmt.Errorf("branch %q is held by unavailable worktree %s; repair the worktree before continuing", target, tree.Path) + } + owner = tree.Path + } + if owner == "" { + return "", nil + } + root, err := git.RootDir() + if err != nil { + return "", fmt.Errorf("finding current worktree: %w", err) + } + if worktree.SamePath(root, owner) { + return "", nil + } + common, err := git.CommonDir() + if err != nil { + return "", fmt.Errorf("finding common Git directory: %w", err) + } + if worktree.SamePath(owner, common) { + // Git can report the administration directory as the main worktree + // path for --separate-git-dir. Only that worktree knows its real root. + localDir, err := git.GitDir() + if err != nil { + return "", fmt.Errorf("finding current worktree Git directory: %w", err) + } + if worktree.SamePath(localDir, common) { + return "", nil + } + return "", fmt.Errorf("branch %q is held by the main worktree, but Git reports only its separate Git directory %s; run this command from the main worktree", target, common) + } + ownerOps, err := git.ForWorktree(owner) + if err != nil { + return "", fmt.Errorf("opening worktree %s: %w", owner, err) + } + ownerCommon, err := ownerOps.CommonDir() + if err != nil { + return "", fmt.Errorf("inspecting worktree %s: %w", owner, err) + } + if !worktree.SamePath(common, ownerCommon) { + return "", fmt.Errorf("worktree %s no longer belongs to this repository", owner) + } + return owner, nil +} + +// Until owner-scoped rebase/sync execution is enabled, include every possible +// write and rollback target, not just the requested rebase range. +func requireLocalBranches(cfg *config.Config, branches []string) error { + seen := make(map[string]bool) + for _, branch := range branches { + if branch == "" || seen[branch] { + continue + } + seen[branch] = true + owner, err := foreignWorktreePath(branch) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" { + reportWorktreeOwner(cfg, branch, owner) + cfg.Errorf("cross-worktree rebase and sync are not supported yet; all affected branches must be unoccupied or checked out in this worktree") + return ErrInvalidArgs + } + } + return nil +} + +func reportWorktreeOwner(cfg *config.Config, target, path string) { + cfg.Infof("Branch %q is checked out in worktree %s; the current checkout was left unchanged", target, path) + cfg.Printf("To work there, run: cd -- '%s'", strings.ReplaceAll(path, "'", "'\\''")) +} + +func checkoutWorktreeBranch(cfg *config.Config, target string, printPath bool) error { + if target == "" { + cfg.Errorf("a target branch is required") + return ErrInvalidArgs + } + if err := git.CheckVersion(); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + owner, err := foreignWorktreePath(target) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" { + if printPath { + _, err := fmt.Fprintln(cfg.Out, owner) + return err + } + reportWorktreeOwner(cfg, target, owner) + return ErrInvalidArgs + } + current, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("finding current branch: %s", err) + return ErrNotInStack + } + var root string + if printPath { + root, err = git.RootDir() + if err != nil || !filepath.IsAbs(root) { + cfg.Errorf("could not determine the absolute current worktree path: %v", err) + return ErrSilent + } + } + if current != target { + release, err := beginStackMutation(cfg, "checkout") + if err != nil { + return err + } + defer release() + // A separate process may have checked out the target while we waited. + owner, err = foreignWorktreePath(target) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + if owner != "" { + if printPath { + _, err := fmt.Fprintln(cfg.Out, owner) + return err + } + reportWorktreeOwner(cfg, target, owner) + return ErrInvalidArgs + } + rebasing, err := git.IsRebaseInProgress() + if err != nil { + cfg.Errorf("checking rebase state before checkout: %s", err) + return ErrSilent + } + picking, err := git.IsCherryPickInProgress() + if err != nil { + cfg.Errorf("checking cherry-pick state before checkout: %s", err) + return ErrSilent + } + if rebasing || picking { + cfg.Errorf("a Git operation is in progress in the current worktree; complete or abort it before switching branches") + return ErrRebaseActive + } + if err := git.CheckoutBranch(target); err != nil { + return err + } + } + if printPath { + _, err := fmt.Fprintln(cfg.Out, root) + return err + } + return nil +} diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 37e29d8..111f0fa 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -6,7 +6,7 @@ description: Install the gh stack CLI and create your first Stacked PR in minute ## Prerequisites - [GitHub CLI](https://cli.github.com/) (`gh`) v2.0 or later, authenticated -- Git 2.20 or later +- Git 2.36 or later - A GitHub repository you can push to ## Install the CLI Extension @@ -93,6 +93,14 @@ gh stack view This shows all branches, their PR links, statuses, and the most recent commit on each. +## Using Existing Worktrees + +Linked worktrees share the same local stack catalog. You can adopt branches already checked out elsewhere with `gh stack init branch-a branch-b` or `gh stack add branch-c`; adoption does not move either checkout. `add`'s commit/stage shortcuts cannot target another worktree. + +`rebase` and `sync` currently require all stack branches and any trunk they update to be unoccupied or checked out in the invoking worktree. They refuse distributed rewrites rather than skipping layers. gh-stack does not auto-stash or manage worktree creation/removal. To navigate across worktrees, use `--print-path` and a shell wrapper that checks the command's exit status before `cd`; see [Working across Git worktrees](/gh-stack/guides/workflows/#working-across-git-worktrees). + +For this core release, `modify` supports a stack within one worktree but temporarily rejects stack branches checked out in other worktrees. + ## What's Next? - [Working with Stacked PRs](/gh-stack/guides/stacked-prs/) — Learn about the PR review and merge experience diff --git a/docs/src/content/docs/guides/modify.md b/docs/src/content/docs/guides/modify.md index f770be4..80e32a4 100644 --- a/docs/src/content/docs/guides/modify.md +++ b/docs/src/content/docs/guides/modify.md @@ -24,6 +24,12 @@ Before running `modify`, ensure: - No rebase is in progress - No PR in the stack is queued for merge - Commit history is linear (run `gh stack rebase` first if needed) +- Git 2.36 or later +- Every stack branch is unoccupied or checked out in the invoking worktree + +Linked worktrees are supported, but **distributed modify is temporarily rejected** before the TUI opens and rechecked before applying. A trunk checked out elsewhere does not block modify: trunk is only read. No worktrees are created, removed, detached, or automatically stashed. + +This also applies when using a separate Git administration directory: modify can use its known main or linked origin, but discovering another main worktree's location may be unavailable. See [Separate Git administration directories](/gh-stack/guides/workflows/#separate-git-administration-directories). ## Opening the TUI @@ -76,6 +82,10 @@ If a rebase conflict occurs during the apply phase, you have two options: If a second conflict occurs after continuing, the same options are available. +The conflict message identifies the originating worktree. Edit and stage the files **there**. You can invoke `--continue` or `--abort` from any linked worktree; Git operations still execute in the recorded origin without changing the invoking worktree's checkout. + +If Git's recorded rebase or cherry-pick is no longer in progress, for example after an external `git rebase --abort`, `modify --continue` refuses and preserves the journal. Use `gh stack modify --abort` to recover through the saved state; continuation will not claim a new branch tip as completed modify work. + ## After modifying If a stack of PRs has been created on GitHub, run: @@ -84,7 +94,9 @@ If a stack of PRs has been created on GitHub, run: gh stack submit ``` -This pushes the updated branches and recreates the stack. The old stack is automatically replaced. +This pushes the updated branches and updates their pull requests. With two or more PRs, the old stack is replaced; a single remaining PR is submitted without creating a new stack object. + +The pending-modify journal is cleared only after all required PR submissions and updates succeed and the local catalog is saved. Failed updates or deselected branches without PRs leave it pending so you can complete the submission with `gh stack submit`. ## Aborting @@ -94,7 +106,15 @@ If you want to discard all changes and restore the stack to its pre-modify state gh stack modify --abort ``` -This also works if `modify` was interrupted (e.g., terminal crash). A pre-modify snapshot is cached locally for state recovery. +This also works if `modify` was interrupted (e.g., terminal crash). The shared `/gh-stack-modify-state` journal records the origin, original checkout, stack identity, and pre-modify snapshot before mutations. Git's native rebase/cherry-pick state stays in the origin's own Git directory. + +Recovery restores changes made by this operation and the original checkout. If the owner is missing, refs were changed externally, or a restore/save fails, recovery stops and retains its journal instead of reporting success. Address the reported problem and retry `--abort`; do not delete the journal to bypass recovery. After a successful modify has reached pending-submit, `--abort` does not undo it and instead directs you to `submit`. + +Clone-wide mutation serialization prevents another gh-stack mutation while modify is applying or paused; read-only views remain available. Pending-submit state is consumed only when submitting its matching stack, never an unrelated stack. + +The mutation lock coordinates gh-stack processes only: arbitrary Git commands, editors, and other tools can still change refs or files. Keep affected worktrees idle during history rewrites. While paused, make only the requested conflict-resolution edits and staging in the reported worktree; do not add unrelated commits to branches that have not yet been processed. + +Legacy journals must be continued or aborted in their original worktree before catalog migration. Nonconflicting legacy catalogs are consolidated with originals preserved; conflicts require reconciliation rather than choosing a definition automatically. ## Limitations @@ -103,3 +123,4 @@ This also works if `modify` was interrupted (e.g., terminal crash). A pre-modify - Cannot move branches between different stacks - Requires an interactive terminal - Reordering and structural changes (drop/fold/insert/rename) cannot be mixed in the same session +- Distributed rename/fold/reorder support is deferred to separate work; all member branches must currently be available in one worktree diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 08f5f9e..32a8133 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -5,6 +5,78 @@ description: Common patterns and workflows for using Stacked PRs effectively. This guide covers the most common workflows for day-to-day use of Stacked PRs, from the standard flow to advanced patterns. +## Working Across Git Worktrees + +With Git 2.36+, you can keep separate stacks in linked worktrees or check out different layers of one stack in different worktrees. Stack membership is shared; it does not belong to whichever directory originally created the stack. + +### Shared catalog and migration + +The catalog is `/gh-stack`, where the common directory is reported by `git rev-parse --path-format=absolute --git-common-dir`. In an ordinary clone this is `.git/gh-stack`. gh-stack's rebase and modify recovery journals also live there. Git's HEAD, index, rebase, and cherry-pick markers remain local to each worktree. + +On upgrade, gh-stack automatically consolidates nonconflicting legacy catalogs, coalesces equivalent definitions, and preserves originals as backups. Sharing a trunk is fine; conflicting branch membership or stack definitions stop migration and identify the source files. Reconcile the conflicting definitions rather than deleting whichever file looks older. Finish or abort legacy operations in their original worktree before migration, and do not run old and new gh-stack versions against the same clone. + +Migration is a prerequisite to the requested operation. It may publish the shared catalog and preservation backups even when the operation later refuses a foreign-owned rewrite. After migration, that refusal leaves refs, indexes, working files, requested stack membership, and remote stacks unchanged. Fetches used for discovery may still have completed. + +### Separate Git administration directories + +Repositories created with `git init --separate-git-dir` keep the administration directory outside the main working directory. Their shared catalog and operations in an explicitly known main or linked worktree remain supported. + +There is a discovery limitation: Git's worktree list can report that administration directory as the main path, without a reverse pointer to the real main working directory. Automatic discovery of the main owner from a linked checkout may therefore be unavailable. If an operation needs that owner's working files, start from the actual main worktree instead. Do not `cd` into an administration directory or infer the checkout from its parent directory. gh-stack does not add a private worktree registry or change Git configuration to repair discovery. + +### Adopt existing branches + +```sh +# Branches can already be checked out in other worktrees +gh stack init auth api frontend +# From the top branch, adopt another existing layer +gh stack add integration +``` + +Adopting an occupied branch records membership without switching either checkout. The command reports its owning path. `add -m`, `-A`, and `-u` cannot be used to commit or stage in another worktree and are rejected before changing membership or staging files. + +### Navigate without stealing a checkout + +Ordinary navigation to a foreign-owned branch fails with its path and leaves your checkout unchanged. For shell integration, `up`, `down`, `top`, `bottom`, `trunk`, and **explicit-target** `checkout` accept `--print-path`: + +| Target | Successful behavior | +|--------|---------------------| +| Checked out in another worktree | Print its absolute owner path; change neither checkout | +| Unoccupied | Check it out here, then print this worktree's absolute path | +| Already current | Print this worktree's absolute path | + +Successful stdout is the raw path plus one newline, with no status text or shell quoting. Diagnostics go to stderr; errors or ambiguous selection leave stdout empty. Path mode never opens a picker, and `checkout --print-path` requires a target. + +This Bash/Zsh wrapper checks the command's exit status before changing directories and quotes paths containing spaces: + +```sh +gscd() { + local target + target=$(gh stack "$@" --print-path) || return $? + if [ -z "$target" ]; then + printf '%s\n' 'gh stack returned an empty path' >&2 + return 1 + fi + cd -- "$target" +} + +gscd bottom +gscd checkout api +``` + +gh-stack does not install shell functions or change your shell's directory. Do not use `eval` or parse human-readable diagnostics for navigation. + +### Rebase, sync, and recover + +`rebase` and `sync` currently use only the initiating worktree. All stack members must be unoccupied or checked out there, including members outside a requested rebase range and merged members that rollback or pruning could touch. A foreign-owned trunk is refused when trunk updates are enabled; `rebase --no-trunk` leaves it alone. Sync checks remote-added and replacement branches before importing them. This conservative limit prevents partial distributed rewrites rather than silently skipping layers. gh-stack never auto-stashes, transfers ownership, or creates/removes worktrees. + +Resolve and stage conflicts in the worktree named by the diagnostic, then run `gh stack rebase --continue` or `--abort` **in that original worktree**. Invoking rebase recovery elsewhere fails before Git mutation. `sync` restores its cascade on conflicts rather than pushing partial results; completed fetches and earlier fast-forwards are outside that rollback boundary. Partial restoration or publication failures retain recovery state. Repair the reported problem and retry recovery in the origin rather than deleting the journal. + +Finish paused operations before changing gh-stack versions or preview stages. Origin-only journals are explicitly marked; a build that cannot interpret a journal's execution lifecycle must leave it intact. If recovery reports an incompatible lifecycle, use the matching build in the recorded origin to finish or abort it instead of editing or removing the journal. + +gh-stack serializes mutations across the clone, including independent stacks. Read-only views remain available. A paused rebase or modify journal blocks new gh-stack mutations until recovery. These locks coordinate **gh-stack only**, not arbitrary Git commands, editors, or other tools. Keep affected worktrees quiescent while history is being rewritten. During a pause, make only the requested conflict-resolution edits and staging in the reported worktree; avoid unrelated commits or checkout changes on participating branches. + +**Core modify limitation:** `modify` works inside a linked worktree only when every stack branch is unoccupied or checked out there. Distributed modify is temporarily rejected before the TUI or apply changes. Trunk ownership alone is allowed. Its `--continue` and `--abort` use the recorded origin even when invoked elsewhere; see [Restructuring stacks](/gh-stack/guides/modify/). + ## Standard Workflow The basic flow: initialize a stack, add branches for each logical unit of work, commit, push, iterate on review feedback, and merge. @@ -209,6 +281,8 @@ After rebasing, push the updated branches: gh stack push ``` +To preserve author dates as committer dates, start with `gh stack rebase --committer-date-is-author-date` (or `--preserve-dates`). This selects Git's merge backend so the date setting survives a conflict. After staging a resolution, use `gh stack rebase --continue`; the continuation uses the settings saved by Git rather than repeating start-only date flags. + `gh stack push` uses `--force-with-lease` to safely update the rebased branches. This is a safe form of force push — it ensures you don't overwrite changes that someone else pushed since your last fetch. If the remote has unexpected changes, the push is rejected and you can investigate. ### Rebase from the CLI vs. the web UI diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index e0e9758..961b851 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -9,12 +9,20 @@ description: Complete reference for all gh stack commands. 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+. :::note[Authentication] The `gh stack` CLI uses your GitHub CLI authentication — run `gh auth login` if you haven't already. ::: +### Worktree behavior + +All linked worktrees share `/gh-stack` and gh-stack recovery journals. Native Git HEAD, index, rebase, and cherry-pick markers remain per-worktree. Mutations are serialized across the clone; read-only views remain available. Nonconflicting legacy catalogs migrate automatically with originals preserved; conflicting definitions require reconciliation. Complete legacy recovery in its original worktree before migration, and do not mix old and new versions in one clone. + +`rebase` and `sync` currently refuse foreign-owned stack members and writable trunks before requested ref, checkout, membership, or remote-stack changes. This includes members outside a rebase range and remote-added sync branches. Prerequisite catalog migration and discovery fetches may already have completed. Neither command auto-stashes or manages worktree creation/removal. Rebase `--continue` and `--abort` must be invoked in the recorded origin; modify recovery may be invoked elsewhere but executes there. Partial recovery failures retain state. See [Working across Git worktrees](/gh-stack/guides/workflows/#working-across-git-worktrees) for details. + +For repositories created with `git init --separate-git-dir`, operations from a known worktree remain supported, but automatic discovery of the main working directory from another checkout may be unavailable. Git's reported main path can be the administration directory rather than a usable checkout; do not use it as a working-directory navigation target. + --- ## Stack Management @@ -35,6 +43,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`. +Branches checked out in other worktrees can be adopted. If the final branch is occupied elsewhere, `init` leaves your current checkout unchanged and reports the owner instead. + Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. **Examples:** @@ -71,6 +81,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. +Existing foreign-owned branches are adopted without switching either checkout. Commit/stage shortcuts (`-m`, `-A`, `-u`) are incompatible with that adoption and fail before staging or changing 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`). @@ -135,6 +147,12 @@ Check out a stack by its stack number, a pull request number, a PR URL, or a bra gh stack checkout [ | | | ] ``` +| Flag | Description | +|------|-------------| +| `--print-path` | Print the target worktree's absolute path; requires an explicit target and never prompts | + +For a foreign-owned target, `--print-path` changes neither checkout. For an unoccupied target, it checks the branch out here before printing this worktree's path. Without the flag, foreign ownership is an error with a path diagnostic, not a successful switch. See [Navigation](#navigation) for the output contract. + A bare number is interpreted first as a stack or PR number (repo-scoped identifiers shown in the GitHub UI). If nothing matches the number, it is tried as a branch name. When a remote stack is referenced, the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. @@ -186,6 +204,9 @@ The command checks these conditions before opening the TUI: 3. No rebase in progress 4. No PR in the stack is queued for merge 5. Commit history must be linear (no merge commits, no diverged branches) +6. All stack branches must be unoccupied or checked out in the invoking worktree + +**Core limitation:** distributed modify is temporarily rejected before the TUI and rechecked before applying. A trunk checked out elsewhere is allowed because modify only reads it. **Operations:** @@ -209,6 +230,8 @@ If a rebase conflict occurs, you can: - Resolve conflicts, stage files, and run `gh stack modify --continue` - Or run `gh stack modify --abort` to abort the operation and restore the stack to the pre-modify state +Resolve and stage in the worktree named by the conflict message. Both recovery flags may be invoked from another linked worktree, but execute in the recorded origin and leave the caller's checkout alone. Failed restore or journal/catalog saves retain recovery state. Pending-submit state is consumed only for the matching stack. + **After modifying:** If a stack of PRs has been created on GitHub, run `gh stack submit` to push the updated branches and recreate the stack. The old stack is automatically replaced. @@ -363,6 +386,8 @@ gh stack rebase [flags] [branch] | `--remote ` | 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 explicitly use Git's merge backend so the setting persists across conflicts. `--continue` uses the saved native rebase settings; it does not resend start-only date options. + | Argument | Description | |----------|-------------| | `[branch]` | Target branch (defaults to the current branch) | @@ -516,6 +541,10 @@ Move between branches in the current stack without having to remember branch nam All navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. +`up`, `down`, `top`, `bottom`, `trunk`, and explicit-target `checkout` support `--print-path`. A foreign-owned target prints its owner path without switching; an unoccupied target is checked out in the invoking worktree before its path is printed. Already-current targets print the current worktree root. + +Successful path-mode stdout is **only the raw absolute path plus one newline**. Diagnostics go to stderr; errors and ambiguous targets produce no stdout, and path mode never prompts. Without the flag, navigation to a branch occupied elsewhere fails and reports its path. A shell wrapper must check the command's exit status before `cd`, quote the path, and never use `eval`; see the [Bash/Zsh example](/gh-stack/guides/workflows/#navigate-without-stealing-a-checkout). + ### `gh stack switch` Interactively switch to another branch in the stack. diff --git a/internal/config/config.go b/internal/config/config.go index 594b57c..e874e88 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,15 @@ type Config struct { // regardless of the terminal state. Used in tests. ForceInteractive bool + // NonInteractive suppresses prompts even when stdout is a terminal. + NonInteractive bool + + // WorktreePathOnly makes checkout resolution skip imports for foreign owners. + WorktreePathOnly bool + + // StackMutation is command-lifetime coordination, never persisted. + StackMutation *StackMutationContext + // SelectFn, when non-nil, is called instead of prompting via the // terminal. Used in tests to simulate interactive selection. SelectFn func(prompt, defaultValue string, options []string) (int, error) @@ -55,6 +64,12 @@ type Config struct { RepoOverride *repository.Repository } +type StackMutationContext struct { + CommonDir string + StateDir string + Kind string +} + // New creates a new Config with terminal-aware output and color support. func New() *Config { terminal := term.FromEnv() @@ -172,7 +187,7 @@ func (c *Config) PRLink(number int, url string) string { } func (c *Config) IsInteractive() bool { - return c.ForceInteractive || c.Terminal.IsTerminalOutput() + return !c.NonInteractive && (c.ForceInteractive || c.Terminal.IsTerminalOutput()) } func (c *Config) Repo() (repository.Repository, error) { diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 761a897..f0f9cba 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -1,6 +1,10 @@ package git -import "fmt" +import ( + "fmt" + "os" + "path/filepath" +) // MockOps is a test double for git operations. // Each field is an optional function that, when set, handles the corresponding @@ -75,7 +79,7 @@ func (m *MockOps) GitDir() (string, error) { if m.GitDirFn != nil { return m.GitDirFn() } - return "/tmp/fake-git-dir", nil + return filepath.Join(os.TempDir(), "fake-git-dir"), nil } func (m *MockOps) CommonDir() (string, error) { @@ -110,7 +114,7 @@ func (m *MockOps) RootDir() (string, error) { if m.RootDirFn != nil { return m.RootDirFn() } - return "/tmp/fake-repo", nil + return filepath.Join(os.TempDir(), "fake-repo"), nil } func (m *MockOps) CurrentBranch() (string, error) { diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 160d54a..bb13ce1 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -2,7 +2,9 @@ package modify import ( "encoding/json" + "errors" "fmt" + "slices" "time" "github.com/github/gh-stack/internal/config" @@ -66,8 +68,8 @@ func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action { effectiveIdx++ } - if n.Removed { - continue // Removed nodes are handled by their pending action + if n.Removed && n.PendingAction == nil { + continue } if n.PendingAction != nil { @@ -85,7 +87,7 @@ func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action { plan = append(plan, action) } - if !n.IsInserted && n.OriginalPosition != i && n.PendingAction == nil { + if !n.Removed && !n.IsInserted && n.OriginalPosition != i && n.PendingAction == nil { plan = append(plan, Action{ Type: "move", Branch: n.Ref.Branch, @@ -109,18 +111,23 @@ func ApplyPlan( currentBranch string, updateBaseSHAs func(*stack.Stack), ) (*modifyview.ApplyResult, *modifyview.ConflictInfo, error) { - // Build the snapshot before any changes - snapshot, err := BuildSnapshot(s) + existing, err := LoadState(gitDir) if err != nil { - return nil, nil, fmt.Errorf("building snapshot: %w", err) + return nil, nil, err + } + if existing != nil { + return nil, nil, fmt.Errorf("a modify journal already exists; finish or abort that operation before applying another plan") + } + ctx, err := CheckWorktrees(s) + if err != nil { + return nil, nil, err } - // Acquire the stack lock before making any changes - lock, err := stack.Lock(gitDir) + // Build the snapshot before any changes + snapshot, err := BuildSnapshot(s) if err != nil { - return nil, nil, fmt.Errorf("acquiring stack lock: %w", err) + return nil, nil, fmt.Errorf("building snapshot: %w", err) } - defer lock.Unlock() // Check branch availability before writing recovery state or changing refs. branchNames := make([]string, 0, len(s.Branches)+1) @@ -143,6 +150,13 @@ func ApplyPlan( } plan := BuildPlan(nodes) + for _, action := range plan { + if action.NewName != "" { + if _, err := git.BranchExists(action.NewName); err != nil { + return nil, nil, fmt.Errorf("checking target branch %s: %w", action.NewName, err) + } + } + } // Find the index of this stack in the stack file for reliable identification stackIndex := -1 @@ -163,10 +177,12 @@ func ApplyPlan( PriorRemoteStackID: s.ID, Snapshot: snapshot, Plan: plan, + OriginalBranch: currentBranch, + Worktrees: ctx, + RenamedBranches: make(map[string]string), + CreatedBranches: make(map[string]string), } - if err := SaveState(gitDir, stateFile); err != nil { - return nil, nil, fmt.Errorf("saving modify state: %w", err) - } + stateFile.RecordStack(s) result := &modifyview.ApplyResult{Success: true} @@ -189,18 +205,43 @@ func ApplyPlan( originalParentTips[b.Branch] = sha } } + stateFile.OriginalRefs = originalParentTips + if err := SaveState(gitDir, stateFile); err != nil { + return nil, nil, fmt.Errorf("saving modify state: %w", err) + } + rollback := func(cause error) error { + return errors.Join(cause, unwindState(cfg, gitDir, stateFile, sf)) + } // Step 1: Renames for i, n := range nodes { if n.PendingAction != nil && n.PendingAction.Type == modifyview.ActionRename { oldName := n.Ref.Branch newName := n.PendingAction.NewName - if err := git.RenameBranch(oldName, newName); err != nil { - unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) - if unwindErr != nil { - return nil, nil, fmt.Errorf("rename failed (%v) and unwind failed (%v)", err, unwindErr) - } - return nil, nil, fmt.Errorf("renaming %s to %s: %w", oldName, newName, err) + ops, err := prepareMutation(stateFile) + if err != nil { + return nil, nil, err + } + exists, err := ops.BranchExists(newName) + if err != nil { + return nil, nil, fmt.Errorf("checking rename target %s: %w", newName, err) + } + if exists { + return nil, nil, rollback(fmt.Errorf("cannot rename %s to %s: branch already exists", oldName, newName)) + } + stateFile.PendingAction = &Action{Type: "rename", Branch: oldName, NewName: newName} + ops, err = startRefMutation(gitDir, stateFile, oldName) + if err != nil { + return nil, nil, err + } + if err := ops.RenameBranch(oldName, newName); err != nil { + return nil, nil, rollback(fmt.Errorf("renaming %s to %s: %w", oldName, newName, err)) + } + ctx.Rename(oldName, newName) + stateFile.RenamedBranches[oldName] = newName + stateFile.PendingAction = nil + if err := ctx.Record(newName); err != nil { + return nil, nil, err } // Update in-memory state @@ -228,6 +269,10 @@ func ApplyPlan( if n.Ref.PullRequest != nil { affectsPRs = true } + stateFile.AffectsPRs = affectsPRs + if err := saveProgress(gitDir, stateFile, s, sf); err != nil { + return nil, nil, err + } cfg.Successf("Renamed %s → %s", oldName, newName) } } @@ -285,13 +330,32 @@ func ApplyPlan( } } - // Create the git branch at the parent's tip - if err := git.CreateBranch(newName, parentBranch); err != nil { - unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) - if unwindErr != nil { - return nil, nil, fmt.Errorf("creating branch %s failed (%v) and unwind failed (%v)", newName, err, unwindErr) - } - return nil, nil, fmt.Errorf("creating branch %s from %s: %w", newName, parentBranch, err) + ops, err := prepareMutation(stateFile) + if err != nil { + return nil, nil, err + } + exists, err := ops.BranchExists(newName) + if err != nil { + return nil, nil, fmt.Errorf("checking insert target %s: %w", newName, err) + } + if exists { + return nil, nil, rollback(fmt.Errorf("cannot insert %s: branch already exists", newName)) + } + parentSHA, err := ops.RevParse(parentBranch) + if err != nil { + return nil, nil, rollback(fmt.Errorf("resolving insert parent %s: %w", parentBranch, err)) + } + stateFile.PendingAction = &Action{Type: string(n.PendingAction.Type), Branch: n.Ref.Branch, NewName: newName} + if err := SaveState(gitDir, stateFile); err != nil { + return nil, nil, err + } + if err := ops.CreateBranch(newName, parentSHA); err != nil { + return nil, nil, rollback(fmt.Errorf("creating branch %s from %s: %w", newName, parentBranch, err)) + } + stateFile.CreatedBranches[newName] = parentSHA + stateFile.PendingAction = nil + if err := ctx.Record(newName); err != nil { + return nil, nil, err } // Insert BranchRef into s.Branches at the correct position @@ -325,6 +389,10 @@ func ApplyPlan( } result.InsertedBranches = append(result.InsertedBranches, newName) + stateFile.AffectsPRs = affectsPRs + if err := saveProgress(gitDir, stateFile, s, sf); err != nil { + return nil, nil, err + } cfg.Successf("Inserted %s after %s", newName, parentBranch) } @@ -384,15 +452,22 @@ func ApplyPlan( if n.PendingAction.Type == modifyview.ActionFoldDown { // Fold-down: cherry-pick the folded branch's commits onto the target. commits, err := git.LogRange(baseBranch, foldBranch) - if err != nil || len(commits) == 0 { + if err != nil { + return nil, nil, rollback(fmt.Errorf("reading commits to fold from %s: %w", foldBranch, err)) + } + if len(commits) == 0 { cfg.Printf("No commits to fold from %s", foldBranch) } else { - if err := git.CheckoutBranch(targetBranch); err != nil { - unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) - if unwindErr != nil { - return nil, nil, fmt.Errorf("checkout failed (%v) and unwind failed (%v)", err, unwindErr) - } - return nil, nil, fmt.Errorf("checking out %s for fold: %w", targetBranch, err) + stateFile.ConflictBranch = foldBranch + stateFile.ConflictType = "cherry_pick" + stateFile.FoldBranch, stateFile.FoldTarget = foldBranch, targetBranch + stateFile.AffectsPRs = affectsPRs + ops, err := startRefMutation(gitDir, stateFile, targetBranch) + if err != nil { + return nil, nil, err + } + if err := ops.CheckoutBranch(targetBranch); err != nil { + return nil, nil, rollback(fmt.Errorf("checking out %s for fold: %w", targetBranch, err)) } shas := make([]string, len(commits)) @@ -400,13 +475,10 @@ func ApplyPlan( shas[len(commits)-1-i] = c.SHA } - git.CherryPickQuit() - - if err := git.CherryPick(shas); err != nil { + if err := ops.CherryPick(shas); err != nil { conflict := &modifyview.ConflictInfo{Branch: foldBranch} - if files, ferr := git.ConflictedFiles(); ferr == nil { - conflict.ConflictedFiles = files - } + files, fileErr := ops.ConflictedFiles() + conflict.ConflictedFiles = files // Compute remaining branches for cascading rebase after cherry-pick resumes. // Since folds happen before cascading rebase (Step 5), all non-merged, non-folded @@ -425,19 +497,18 @@ func ApplyPlan( stateFile.FoldBranch = foldBranch stateFile.FoldTarget = targetBranch stateFile.RemainingBranches = remaining - stateFile.OriginalBranch = currentBranch - stateFile.OriginalRefs = originalParentTips stateFile.AffectsPRs = affectsPRs - if saveErr := SaveState(gitDir, stateFile); saveErr != nil { - cfg.Warningf("failed to save conflict state: %v", saveErr) + if saveErr := saveProgress(gitDir, stateFile, s, sf); saveErr != nil { + return nil, nil, errors.Join(err, saveErr) } - - // Save stack metadata so far - if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { - cfg.Warningf("failed to save stack metadata: %v", saveErr) + if fileErr != nil { + return nil, nil, errors.Join(err, fmt.Errorf("reading conflicts in %s: %w", ctx.Origin.Path, fileErr)) } - return nil, conflict, fmt.Errorf("cherry-pick conflict folding %s into %s", foldBranch, targetBranch) + return nil, conflict, fmt.Errorf("cherry-pick conflict folding %s into %s in %s", foldBranch, targetBranch, ctx.Origin.Path) + } + if err := ctx.Record(targetBranch); err != nil { + return nil, nil, err } cfg.Successf("Folded %s into %s (%d commits)", foldBranch, targetBranch, len(commits)) @@ -457,6 +528,10 @@ func ApplyPlan( if foldIdx >= 0 && foldIdx < len(s.Branches) { s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...) } + stateFile.AffectsPRs = affectsPRs + if err := saveProgress(gitDir, stateFile, s, sf); err != nil { + return nil, nil, err + } } // Step 4: Drops — remove from stack metadata @@ -482,6 +557,10 @@ func ApplyPlan( } s.Branches = append(s.Branches[:dropIdx], s.Branches[dropIdx+1:]...) + stateFile.AffectsPRs = affectsPRs + if err := saveProgress(gitDir, stateFile, s, sf); err != nil { + return nil, nil, err + } cfg.Successf("Dropped %s from stack", dropBranch) } @@ -549,127 +628,113 @@ func ApplyPlan( } s.Branches = newBranches + if err := saveProgress(gitDir, stateFile, s, sf); err != nil { + return nil, nil, err + } } - // Step 6: Cascading rebase — rebase each active branch onto its new parent. - // Use the original parent tip SHA as the oldBase for --onto, so that only - // the branch's own commits are replayed onto the new parent. - for i, b := range s.Branches { - if b.IsMerged() { + // Step 6: Replay each active branch's original commit range onto its new parent. + moved, conflict, err := rebaseRemaining(cfg, gitDir, stateFile, s, sf, s.BranchNames()) + if err != nil { + return nil, conflict, err + } + result.MovedBranches = moved + + // Check out the best branch — the original if it's still in the stack, + // otherwise the nearest surviving branch. + targetBranch := resolveCheckoutBranch(currentBranch, plan, snapshot, s) + if err := ctx.RestoreOrigin(targetBranch); err != nil { + return nil, nil, err + } + if targetBranch != currentBranch { + cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, currentBranch) + } + + // Update base SHAs + updateBaseSHAs(s) + + // Update state file phase — only require submit when PRs are affected + result.NeedsSubmit = s.ID != "" && stateFile.AffectsPRs + if err := finishApply(gitDir, stateFile, s, sf, result.NeedsSubmit); err != nil { + return nil, nil, err + } + + return result, nil, nil +} + +func rebaseRemaining(cfg *config.Config, dir string, state *StateFile, s *stack.Stack, sf *stack.StackFile, branches []string) (int, *modifyview.ConflictInfo, error) { + moved := 0 + for i, name := range branches { + index := s.IndexOf(name) + if index < 0 { + return moved, nil, fmt.Errorf("branch %s is missing from the recorded stack; recovery state was retained", name) + } + branch := s.Branches[index] + if branch.IsMerged() { continue } - - var newBase string - if i == 0 { - newBase = s.Trunk.Branch - } else { - newBase = s.ActiveBaseBranch(b.Branch) + ops, err := state.Worktrees.OriginOps() + if err != nil { + return moved, nil, err } - - // Use the branch's original parent tip as the oldBase for --onto. - // This ensures we replay only this branch's unique commits. - oldBase, hasOldBase := originalParentTips[b.Branch] - if !hasOldBase { - // No original parent recorded — try merge-base as fallback - if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil { - oldBase = mb - } else { - continue + newBase := s.ActiveBaseBranch(name) + oldBase := state.OriginalRefs[name] + if oldBase == "" { + oldBase, err = ops.MergeBase(newBase, name) + if err != nil { + return moved, nil, fmt.Errorf("finding original base for %s: %w", name, err) } } - - // Check if rebase is actually needed - isAnc, ancErr := git.IsAncestor(newBase, b.Branch) - if ancErr == nil && isAnc { - if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase { - continue // No rebase needed - } + ancestor, err := ops.IsAncestor(newBase, name) + if err != nil { + return moved, nil, fmt.Errorf("checking ancestry of %s: %w", name, err) } - - if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { - if git.IsRebaseStartError(err) { - if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { - cfg.Warningf("failed to save stack metadata: %v", saveErr) - } - return nil, nil, fmt.Errorf("could not start rebase of %s onto %s: %w", b.Branch, newBase, err) + if ancestor { + base, err := ops.MergeBase(newBase, name) + if err != nil { + return moved, nil, fmt.Errorf("finding merge base for %s: %w", name, err) } - - conflict := &modifyview.ConflictInfo{ - Branch: b.Branch, + if base == oldBase { + continue } - if files, ferr := git.ConflictedFiles(); ferr == nil { - conflict.ConflictedFiles = files + } + state.ConflictBranch = name + state.ConflictType = "rebase" + state.RemainingBranches = append([]string{}, branches[i+1:]...) + state.AffectsPRs = state.AffectsPRs || branch.PullRequest != nil + ops, err = startRefMutation(dir, state, name) + if err != nil { + return moved, nil, err + } + if err := ops.RebaseOnto(newBase, oldBase, name, git.RebaseOpts{}); err != nil { + state.Phase = PhaseConflict + if git.IsRebaseStartError(err) { + state.ConflictType = "rebase_start" } - - if b.PullRequest != nil { - affectsPRs = true + if saveErr := saveProgress(dir, state, s, sf); saveErr != nil { + return moved, nil, errors.Join(err, saveErr) } - - // Save conflict state so --continue can resume - remaining := make([]string, 0) - for j := i + 1; j < len(s.Branches); j++ { - if !s.Branches[j].IsMerged() { - remaining = append(remaining, s.Branches[j].Branch) - } - } - stateFile.Phase = PhaseConflict - stateFile.ConflictBranch = b.Branch - stateFile.ConflictType = "rebase" - stateFile.RemainingBranches = remaining - stateFile.OriginalBranch = currentBranch - stateFile.OriginalRefs = originalParentTips - stateFile.AffectsPRs = affectsPRs - if saveErr := SaveState(gitDir, stateFile); saveErr != nil { - cfg.Warningf("failed to save conflict state: %v", saveErr) + if git.IsRebaseStartError(err) { + return moved, nil, fmt.Errorf("could not start rebase of %s onto %s in %s: %w", name, newBase, state.Worktrees.Origin.Path, err) } - - // Save stack metadata so far (renames, folds, drops already applied) - if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { - cfg.Warningf("failed to save stack metadata: %v", saveErr) + files, fileErr := ops.ConflictedFiles() + if fileErr != nil { + return moved, nil, errors.Join(err, fmt.Errorf("reading conflicts in %s: %w", state.Worktrees.Origin.Path, fileErr)) } - - return nil, conflict, fmt.Errorf("rebase conflict on %s", b.Branch) - } - - cfg.Successf("Rebased %s onto %s", b.Branch, newBase) - if b.PullRequest != nil { - affectsPRs = true + return moved, &modifyview.ConflictInfo{Branch: name, ConflictedFiles: files}, + fmt.Errorf("rebase conflict on %s in %s", name, state.Worktrees.Origin.Path) } - result.MovedBranches++ - } - - // Check out the best branch — the original if it's still in the stack, - // otherwise the nearest surviving branch. - targetBranch := resolveCheckoutBranch(currentBranch, plan, snapshot, s) - if err := git.CheckoutBranch(targetBranch); err == nil { - if targetBranch != currentBranch { - cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, currentBranch) + if err := state.Worktrees.Record(name); err != nil { + return moved, nil, err } - } - - // Update base SHAs - updateBaseSHAs(s) - - // Update state file phase — only require submit when PRs are affected - result.NeedsSubmit = s.ID != "" && affectsPRs - if result.NeedsSubmit { - stateFile.Phase = PhasePendingSubmit - if err := SaveState(gitDir, stateFile); err != nil { - cfg.Warningf("failed to update modify state: %s", err) + state.ConflictBranch, state.ConflictType = "", "cascade" + if err := saveProgress(dir, state, s, sf); err != nil { + return moved, nil, err } + cfg.Successf("Rebased %s onto %s", name, newBase) + moved++ } - - // Save stack metadata — this must succeed since git refs have been rewritten - if err := stack.SaveWithLock(gitDir, sf, lock); err != nil { - return nil, nil, fmt.Errorf("saving stack metadata: %w", err) - } - - // Clear state after metadata save succeeds to preserve --abort recovery - if !result.NeedsSubmit { - ClearState(gitDir) - } - - return result, nil, nil + return moved, nil, nil } // resolveCheckoutBranch determines which branch to check out after a modify @@ -801,38 +866,69 @@ func ContinueApply( return fmt.Errorf("loading stack: %w", err) } - // Acquire lock for the duration of the operation - lock, err := stack.Lock(gitDir) + s, err := findStack(state, sf) if err != nil { - return fmt.Errorf("acquiring stack lock: %w", err) + return err } - defer lock.Unlock() - - // Find the stack using the saved index for reliable identification. - var s *stack.Stack - if state.StackIndex >= 0 && state.StackIndex < len(sf.Stacks) { - s = &sf.Stacks[state.StackIndex] + if state.StackBranches != nil && (state.StackName != s.Trunk.Branch || !slices.Equal(state.StackBranches, s.BranchNames())) { + return fmt.Errorf("the modify catalog update did not complete or the stack changed; run `gh stack modify --abort` to recover") } - if s == nil { - return fmt.Errorf("stack at index %d not found (stack file may have changed)", state.StackIndex) + ctx, err := recoveryContext(gitDir, state) + if err != nil { + return err + } + ops, err := ctx.OriginOps() + if err != nil { + return err + } + inProgress, err := ops.IsRebaseInProgress() + if err != nil { + return fmt.Errorf("checking rebase state before continuation: %w", err) + } + picking, err := ops.IsCherryPickInProgress() + if err != nil { + return fmt.Errorf("checking cherry-pick state before continuation: %w", err) + } + switch state.ConflictType { + case "", "rebase": + if !inProgress { + return fmt.Errorf("the rebase recorded by modify is no longer in progress in %s; recovery state was retained, run `gh stack modify --abort` to recover", ctx.Origin.Path) + } + case "cherry_pick": + if !picking { + return fmt.Errorf("the cherry-pick recorded by modify is no longer in progress in %s; recovery state was retained, run `gh stack modify --abort` to recover", ctx.Origin.Path) + } + } + existing, err := recoveryBranchAvailability(state, ops) + if err != nil { + return err + } + if state.Worktrees == nil { + if err := adoptLegacyContext(state, ctx, ops, existing); err != nil { + return err + } + } + state.RecordStack(s) + if err := SaveState(gitDir, state); err != nil { + return err } - - // Carry forward whether any prior actions already affected PRs - affectsPRs := state.AffectsPRs // Check the conflict branch itself if idx := s.IndexOf(state.ConflictBranch); idx >= 0 && s.Branches[idx].PullRequest != nil { - affectsPRs = true + state.AffectsPRs = true } - remainingBranches := state.RemainingBranches + remainingBranches := append([]string{}, state.RemainingBranches...) // Finish the in-progress git operation, or resume at a rebase that was // previously refused before it could start. switch state.ConflictType { case "cherry_pick": - if err := git.CherryPickContinue(); err != nil { - return fmt.Errorf("cherry-pick continue failed — resolve remaining conflicts and try again: %w", err) + if err := ops.CherryPickContinue(); err != nil { + return fmt.Errorf("cherry-pick continue failed in %s — resolve remaining conflicts and try again: %w", ctx.Origin.Path, err) + } + if err := ctx.Record(state.FoldTarget); err != nil { + return err } cfg.Successf("Folded %s into %s", state.FoldBranch, state.FoldTarget) @@ -843,148 +939,49 @@ func ContinueApply( } case "", "rebase": // Rebase conflict - inProgress, err := git.IsRebaseInProgress() - if err != nil { - return fmt.Errorf("checking rebase state: %w", err) + if err := ops.RebaseContinue(git.RebaseOpts{}); err != nil { + return fmt.Errorf("rebase continue failed in %s — resolve remaining conflicts and try again: %w", ctx.Origin.Path, err) } - if inProgress { - if err := git.RebaseContinue(git.RebaseOpts{}); err != nil { - return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) - } + if err := ctx.Record(state.ConflictBranch); err != nil { + return err } cfg.Successf("Rebased %s", state.ConflictBranch) case "rebase_start": remainingBranches = append([]string{state.ConflictBranch}, remainingBranches...) + case "cascade": default: return fmt.Errorf("unknown modify conflict type %q", state.ConflictType) } - // Continue cascading rebase for remaining branches - for _, branchName := range remainingBranches { - idx := s.IndexOf(branchName) - if idx < 0 { - cfg.Warningf("branch %s no longer in stack, skipping", branchName) - continue - } - b := s.Branches[idx] - if b.IsMerged() { - continue - } - - var newBase string - if idx == 0 { - newBase = s.Trunk.Branch - } else { - newBase = s.ActiveBaseBranch(b.Branch) - } - - // Use original parent tip or merge-base as oldBase - oldBase := "" - if state.OriginalRefs != nil { - oldBase = state.OriginalRefs[b.Branch] - } - if oldBase == "" { - if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil { - oldBase = mb - } else { - continue - } - } - - // Check if rebase is needed - isAnc, ancErr := git.IsAncestor(newBase, b.Branch) - if ancErr == nil && isAnc { - if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase { - continue - } - } - - if err := git.RebaseOnto(newBase, oldBase, b.Branch, git.RebaseOpts{}); err != nil { - if git.IsRebaseStartError(err) { - remaining := make([]string, 0) - foundCurrent := false - for _, rn := range remainingBranches { - if rn == branchName { - foundCurrent = true - continue - } - if foundCurrent { - remaining = append(remaining, rn) - } - } - state.ConflictBranch = branchName - state.ConflictType = "rebase_start" - state.RemainingBranches = remaining - state.AffectsPRs = affectsPRs - if saveErr := SaveState(gitDir, state); saveErr != nil { - cfg.Warningf("failed to update modify state: %v", saveErr) - } - if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { - cfg.Warningf("failed to save stack metadata: %v", saveErr) - } - return fmt.Errorf("could not start rebase of %s onto %s: %w", b.Branch, newBase, err) - } - - // Another conflict — update state and bail - remaining := make([]string, 0) - foundCurrent := false - for _, rn := range remainingBranches { - if rn == branchName { - foundCurrent = true - continue - } - if foundCurrent { - remaining = append(remaining, rn) - } - } - state.ConflictBranch = branchName - // These remaining branches are always rebased via RebaseOnto, so - // the in-progress operation is a rebase. Update ConflictType in - // case the original conflict was a cherry-pick (fold-down) — a - // stale "cherry_pick" here would make the next --continue call - // CherryPickContinue and fail. - state.ConflictType = "rebase" - state.RemainingBranches = remaining - state.AffectsPRs = affectsPRs - _ = SaveState(gitDir, state) - - // Persist the stack metadata so far. A fold-down removes the - // folded branch from the in-memory stack (above) before the - // cascade rebase runs. If we don't save it here, the next - // --continue re-reads the on-disk metadata (folded branch still - // present) and — because ConflictType is now "rebase" — skips the - // fold-removal block, silently resurrecting the folded branch as a - // phantom entry. Mirrors ApplyPlan's save-on-conflict. - if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil { - cfg.Warningf("failed to save stack metadata: %v", saveErr) - } - cfg.Warningf("Conflict rebasing %s", branchName) - if files, ferr := git.ConflictedFiles(); ferr == nil { - for _, f := range files { - cfg.Printf(" %s", f) - } + state.ConflictBranch, state.ConflictType = "", "cascade" + state.RemainingBranches = remainingBranches + if err := saveProgress(gitDir, state, s, sf); err != nil { + return err + } + if _, conflict, err := rebaseRemaining(cfg, gitDir, state, s, sf, remainingBranches); err != nil { + if conflict != nil { + cfg.Warningf("Conflict rebasing %s in %s", conflict.Branch, ctx.Origin.Path) + for _, file := range conflict.ConflictedFiles { + cfg.Printf(" %s", file) } cfg.Printf("") - cfg.Printf("Resolve the conflicts, stage with `%s`, then run `%s`", + cfg.Printf("Resolve the conflicts in %s, stage with `%s`, then run `%s`", + ctx.Origin.Path, cfg.ColorCyan("git add "), cfg.ColorCyan("gh stack modify --continue")) cfg.Printf("Or restore the stack with `%s`", cfg.ColorCyan("gh stack modify --abort")) - return fmt.Errorf("rebase conflict on %s", branchName) - } - - cfg.Successf("Rebased %s onto %s", branchName, newBase) - if b.PullRequest != nil { - affectsPRs = true } + return err } // All rebases done — check out the best branch if state.OriginalBranch != "" { targetBranch := resolveCheckoutBranch(state.OriginalBranch, state.Plan, state.Snapshot, s) - if err := git.CheckoutBranch(targetBranch); err == nil { - if targetBranch != state.OriginalBranch { - cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, state.OriginalBranch) - } + if err := ctx.RestoreOrigin(targetBranch); err != nil { + return err + } + if targetBranch != state.OriginalBranch { + cfg.Printf("Switched to %s (original branch %s is no longer in the stack)", targetBranch, state.OriginalBranch) } } @@ -992,25 +989,9 @@ func ContinueApply( updateBaseSHAs(s) // Transition to pending_submit only when PRs are affected - needsSubmit := s.ID != "" && affectsPRs - if needsSubmit { - state.Phase = PhasePendingSubmit - state.ConflictBranch = "" - state.RemainingBranches = nil - state.OriginalRefs = nil - if err := SaveState(gitDir, state); err != nil { - cfg.Warningf("failed to update modify state: %s", err) - } - } - - // Save stack metadata - if err := stack.SaveWithLock(gitDir, sf, lock); err != nil { - cfg.Warningf("failed to save stack: %v", err) - } - - // Clear state after metadata save succeeds to preserve --abort recovery - if !needsSubmit { - ClearState(gitDir) + needsSubmit := s.ID != "" && state.AffectsPRs + if err := finishApply(gitDir, state, s, sf, needsSubmit); err != nil { + return err } cfg.Successf("Stack modified successfully") @@ -1023,102 +1004,27 @@ func ContinueApply( } // Unwind restores the stack to its pre-modify state using the snapshot. -// stackIndex is the index of the stack in sf.Stacks at modify start time. +// stackIndex is retained for legacy callers, but is never used as an identity. func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int, sf *stack.StackFile, plan []Action) error { - // Abort any in-progress rebase or cherry-pick so the working tree and - // index are clean before we restore branch tips. A fold-down conflict - // leaves an in-progress cherry-pick with an unmerged index; without - // aborting it first, the restore checkouts below would fail. - rebasing, err := git.IsRebaseInProgress() - if err != nil { - return fmt.Errorf("checking rebase state before unwind: %w", err) - } - picking, err := git.IsCherryPickInProgress() + state, err := LoadState(gitDir) if err != nil { - return fmt.Errorf("checking cherry-pick state before unwind: %w", err) + return err } - snapshotNames := make(map[string]bool, len(snapshot.Branches)) - branchExists := make(map[string]bool) - for _, bs := range snapshot.Branches { - snapshotNames[bs.Name] = true - exists, err := git.BranchExists(bs.Name) - if err != nil { - return fmt.Errorf("checking branch %s before unwind: %w", bs.Name, err) - } - branchExists[bs.Name] = exists - } - for _, action := range plan { - if action.NewName != "" && !snapshotNames[action.NewName] && - (action.Type == "rename" || action.Type == "insert_below" || action.Type == "insert_above") { - exists, err := git.BranchExists(action.NewName) - if err != nil { - return fmt.Errorf("checking branch %s before cleanup: %w", action.NewName, err) - } - branchExists[action.NewName] = exists + if state == nil { + state = &StateFile{ + SchemaVersion: 1, StackIndex: stackIndex, Phase: PhaseApplying, + Snapshot: snapshot, Plan: plan, } - } - if rebasing { - _ = git.RebaseAbort() - } - if picking { - _ = git.CherryPickAbort() - } - - // Restore branch tips - for _, bs := range snapshot.Branches { - if !branchExists[bs.Name] { - // Branch was renamed — try to find it by SHA and recreate - if err := git.CreateBranch(bs.Name, bs.TipSHA); err != nil { - cfg.Warningf("failed to restore branch %s: %v", bs.Name, err) - continue - } - } else { - if err := git.CheckoutBranch(bs.Name); err != nil { - cfg.Warningf("failed to checkout %s for unwind: %v", bs.Name, err) - continue - } - if err := git.ResetHard(bs.TipSHA); err != nil { - cfg.Warningf("failed to reset %s to %s: %v", bs.Name, bs.TipSHA[:7], err) - continue - } + } else { + var original stack.Stack + if err := json.Unmarshal(snapshot.StackMetadata, &original); err != nil { + return fmt.Errorf("reading recovery snapshot: %w", err) } - } - - // Clean up branches created by renames or inserts during the partial apply - for _, action := range plan { - if action.NewName != "" && (action.Type == "rename" || action.Type == "insert_below" || action.Type == "insert_above") { - if !snapshotNames[action.NewName] && branchExists[action.NewName] { - _ = git.DeleteBranch(action.NewName, true) - } + if !MatchesStack(&StateFile{Snapshot: state.Snapshot}, &original) { + return fmt.Errorf("modify journal belongs to a different stack; recovery state was retained") } } - - // Restore stack metadata from snapshot - var restoredStack stack.Stack - if err := json.Unmarshal(snapshot.StackMetadata, &restoredStack); err != nil { - return fmt.Errorf("restoring stack metadata: %w", err) - } - - // Replace the stack at the saved index - if stackIndex >= 0 && stackIndex < len(sf.Stacks) { - sf.Stacks[stackIndex] = restoredStack - } - - // Save restored stack - if err := stack.Save(gitDir, sf); err != nil { - cfg.Warningf("failed to save restored stack: %v", err) - } - - // Clear state file - ClearState(gitDir) - - // Checkout the first snapshot branch - if len(snapshot.Branches) > 0 { - _ = git.CheckoutBranch(snapshot.Branches[0].Name) - } - - cfg.Successf("Stack restored to pre-modify state") - return nil + return unwindState(cfg, gitDir, state, sf) } // UnwindFromStateFile restores the stack from a modify state file (for --abort). @@ -1136,5 +1042,5 @@ func UnwindFromStateFile(cfg *config.Config, gitDir string) error { return fmt.Errorf("loading stack: %w", err) } - return Unwind(cfg, gitDir, state.Snapshot, state.StackIndex, sf, state.Plan) + return unwindState(cfg, gitDir, state, sf) } diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index 1130040..ad6eded 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -3,8 +3,11 @@ package modify import ( "encoding/json" "errors" + "fmt" "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/github/gh-stack/internal/config" @@ -12,6 +15,7 @@ import ( "github.com/github/gh-stack/internal/stack" "github.com/github/gh-stack/internal/tui/modifyview" "github.com/github/gh-stack/internal/tui/stackview" + "github.com/github/gh-stack/internal/worktree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,7 +49,7 @@ func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { return &git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "main", nil }, - BranchExistsFn: func(name string) (bool, error) { return true, nil }, + BranchExistsFn: func(name string) (bool, error) { _, ok := branchSHAs[name]; return ok, nil }, RevParseFn: func(ref string) (string, error) { if sha, ok := branchSHAs[ref]; ok { return sha, nil @@ -57,18 +61,40 @@ func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { CheckoutBranchFn: func(string) error { return nil }, RebaseOntoFn: func(string, string, string, git.RebaseOpts) error { return nil }, IsRebaseInProgressFn: func() (bool, error) { return false, nil }, - RenameBranchFn: func(string, string) error { return nil }, + RenameBranchFn: func(oldName, newName string) error { + branchSHAs[newName] = branchSHAs[oldName] + delete(branchSHAs, oldName) + return nil + }, LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { return []git.CommitInfo{{SHA: "commit-1"}, {SHA: "commit-2"}}, nil }, CherryPickFn: func([]string) error { return nil }, ConflictedFilesFn: func() ([]string, error) { return nil, nil }, ResetHardFn: func(string) error { return nil }, - CreateBranchFn: func(string, string) error { return nil }, - RebaseAbortFn: func() error { return nil }, + CreateBranchFn: func(name, base string) error { + branchSHAs[name] = base + return nil + }, + RebaseAbortFn: func() error { return nil }, } } +func requireWorktree(t *testing.T, parent git.Ops, path string) git.Ops { + t.Helper() + ops, err := parent.ForWorktree(path) + require.NoError(t, err) + require.NotNil(t, ops) + return ops +} + +func requireGitState(t *testing.T, query func() (bool, error)) bool { + t.Helper() + state, err := query() + require.NoError(t, err) + return state +} + // makeNodes creates ModifyBranchNodes from a stack for testing. func makeNodes(s *stack.Stack) []modifyview.ModifyBranchNode { nodes := make([]modifyview.ModifyBranchNode, len(s.Branches)) @@ -138,12 +164,16 @@ func TestUnwind_StateLookupFailurePreservesJournal(t *testing.T) { Branches: []BranchSnapshot{{Name: "A", TipSHA: "original"}}, StackMetadata: metadata, } - state := &StateFile{SchemaVersion: 1, Phase: PhaseConflict, Snapshot: snapshot} + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, Snapshot: snapshot, + Plan: []Action{{Type: "rename", Branch: "A", NewName: "renamed"}}, + } require.NoError(t, SaveState(gitDir, state)) before, err := os.ReadFile(StatePath(gitDir)) require.NoError(t, err) lookupErr := errors.New("state lookup failed") mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, IsRebaseInProgressFn: func() (bool, error) { if query == "rebase" { return false, lookupErr @@ -180,7 +210,7 @@ func TestUnwind_StateLookupFailurePreservesJournal(t *testing.T) { cfg, _, _ := config.NewTestConfig() defer cfg.Out.Close() defer cfg.Err.Close() - err = Unwind(cfg, gitDir, snapshot, 0, sf, []Action{{Type: "rename", Branch: "A", NewName: "renamed"}}) + err = Unwind(cfg, gitDir, snapshot, 0, sf, state.Plan) require.ErrorIs(t, err, lookupErr) after, err := os.ReadFile(StatePath(gitDir)) require.NoError(t, err) @@ -189,6 +219,167 @@ func TestUnwind_StateLookupFailurePreservesJournal(t *testing.T) { } } +func TestModifyRecovery_CheckedQueriesPreserveJournal(t *testing.T) { + for _, recordedOrigin := range []bool{false, true} { + for _, action := range []string{"continue", "abort"} { + for _, query := range []string{"factory", "rebase", "cherry-pick", "branch"} { + t.Run(fmt.Sprintf("recorded=%t/%s/%s", recordedOrigin, action, query), func(t *testing.T) { + dir, origin := t.TempDir(), t.TempDir() + sf := writeTestStackFile(t, dir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}}, + }) + metadata, err := json.Marshal(sf.Stacks[0]) + require.NoError(t, err) + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "rebase", + OriginalBranch: "A", ConflictBranch: "A", + Snapshot: Snapshot{ + Branches: []BranchSnapshot{{Name: "A", TipSHA: "original"}}, StackMetadata: metadata, + }, + } + if recordedOrigin { + state.Worktrees = &worktree.Context{Origin: worktree.Location{Path: origin, ID: "."}} + } + require.NoError(t, SaveState(dir, state)) + before, err := os.ReadFile(StatePath(dir)) + require.NoError(t, err) + catalog, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + lookupErr := errors.New("checked query failed") + mock := newApplyMock(dir, map[string]string{"A": "original", "main": "trunk"}) + mock.RootDirFn = func() (string, error) { return origin, nil } + mock.IsRebaseInProgressFn = func() (bool, error) { return true, nil } + if query == "factory" { + mock.ForWorktreeFn = func(string) (git.Ops, error) { return nil, lookupErr } + } else if query == "rebase" { + mock.IsRebaseInProgressFn = func() (bool, error) { return false, lookupErr } + } else if query == "cherry-pick" { + mock.IsCherryPickInProgressFn = func() (bool, error) { return false, lookupErr } + } else { + mock.BranchExistsFn = func(string) (bool, error) { return false, lookupErr } + } + mock.RebaseContinueFn = func(git.RebaseOpts) error { t.Fatal("must not continue after query failure"); return nil } + mock.RebaseAbortFn = func() error { t.Fatal("must not abort after query failure"); return nil } + mock.CheckoutBranchFn = func(string) error { t.Fatal("must not change checkout after query failure"); return nil } + mock.ResetHardFn = func(string) error { t.Fatal("must not reset after query failure"); return nil } + restore := git.SetOps(mock) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + if action == "continue" { + err = ContinueApply(cfg, dir, noopUpdateBaseSHAs) + } else { + err = UnwindFromStateFile(cfg, dir) + } + + require.ErrorIs(t, err, lookupErr) + after, err := os.ReadFile(StatePath(dir)) + require.NoError(t, err) + assert.Equal(t, before, after) + afterCatalog, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, catalog, afterCatalog) + }) + } + } + } +} + +func TestContinueApply_MissingNativeOperationPreservesJournal(t *testing.T) { + for _, recordedOrigin := range []bool{false, true} { + for _, conflictType := range []string{"", "rebase", "cherry_pick"} { + t.Run(fmt.Sprintf("recorded=%t/type=%s", recordedOrigin, conflictType), func(t *testing.T) { + dir, origin := t.TempDir(), t.TempDir() + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + } + writeTestStackFile(t, dir, s) + metadata, err := json.Marshal(s) + require.NoError(t, err) + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: conflictType, + OriginalBranch: "A", ConflictBranch: "A", RemainingBranches: []string{"B"}, + FoldBranch: "B", FoldTarget: "A", + OriginalRefs: map[string]string{"B": "original-A"}, + Snapshot: Snapshot{ + Branches: []BranchSnapshot{{Name: "A", TipSHA: "original-A"}, {Name: "B", TipSHA: "original-B"}}, + StackMetadata: metadata, + }, + } + if recordedOrigin { + state.Worktrees = &worktree.Context{ + Origin: worktree.Location{Path: origin, ID: "."}, + Pending: "A", PendingBefore: "original-A", + } + } + require.NoError(t, SaveState(dir, state)) + before, err := os.ReadFile(StatePath(dir)) + require.NoError(t, err) + catalog, err := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, err) + refs := map[string]string{"main": "trunk", "A": "original-A", "B": "original-B"} + mock := newApplyMock(dir, refs) + mock.RootDirFn = func() (string, error) { return origin, nil } + mock.CurrentBranchFn = func() (string, error) { return "A", nil } + rebasing, picking := conflictType != "cherry_pick", conflictType == "cherry_pick" + mock.IsRebaseInProgressFn = func() (bool, error) { return rebasing, nil } + mock.IsCherryPickInProgressFn = func() (bool, error) { return picking, nil } + mock.RebaseAbortFn = func() error { rebasing = false; return nil } + mock.CherryPickAbortFn = func() error { picking = false; return nil } + nativeContinues, refReads, mutations := 0, 0, 0 + mock.RebaseContinueFn = func(git.RebaseOpts) error { + nativeContinues++ + return errors.New("no native rebase in progress") + } + mock.CherryPickContinueFn = func() error { + nativeContinues++ + return errors.New("no native cherry-pick in progress") + } + mock.RevParseFn = func(ref string) (string, error) { + refReads++ + return refs[ref], nil + } + mock.RebaseOntoFn = func(_, _, branch string, _ git.RebaseOpts) error { + mutations++ + refs[branch] = "replayed" + return nil + } + mock.CheckoutBranchFn = func(string) error { mutations++; return nil } + restore := git.SetOps(mock) + defer restore() + // An external abort removes Git's marker, then a new commit appears. + if picking { + require.NoError(t, mock.CherryPickAbort()) + } else { + require.NoError(t, mock.RebaseAbort()) + } + refs["A"] = "external-commit" + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = ContinueApply(cfg, dir, noopUpdateBaseSHAs) + + assert.ErrorContains(t, err, "gh stack modify --abort") + assert.Zero(t, nativeContinues) + assert.Zero(t, refReads, "must not claim the current tip as completed modify work") + assert.Zero(t, mutations) + assert.Equal(t, "external-commit", refs["A"]) + assert.Equal(t, "original-B", refs["B"]) + after, readErr := os.ReadFile(StatePath(dir)) + assert.NoError(t, readErr) + assert.Equal(t, before, after) + afterCatalog, readErr := os.ReadFile(filepath.Join(dir, "gh-stack")) + require.NoError(t, readErr) + assert.Equal(t, catalog, afterCatalog) + }) + } + } +} + // ─── BuildSnapshot ─────────────────────────────────────────────────────────── func TestBuildSnapshot(t *testing.T) { @@ -299,9 +490,7 @@ func TestBuildPlan_VariousActions(t *testing.T) { assert.Equal(t, 2, plan[1].NewPosition) }) - t.Run("removed nodes with drop action not in plan directly", func(t *testing.T) { - // BuildPlan skips Removed nodes — the drop is recorded by the non-removed - // logic. But nodes with PendingAction and NOT Removed do get recorded. + t.Run("removed nodes retain recovery actions", func(t *testing.T) { nodes := []modifyview.ModifyBranchNode{ { BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "A"}}, @@ -311,8 +500,7 @@ func TestBuildPlan_VariousActions(t *testing.T) { }, } plan := BuildPlan(nodes) - // Removed == true, so it's skipped in BuildPlan - assert.Empty(t, plan) + assert.Equal(t, []Action{{Type: "drop", Branch: "A"}}, plan) }) } @@ -871,8 +1059,8 @@ func TestApplyPlan_ConflictDuringCherryPick(t *testing.T) { // ─── ContinueApply: Multi-Stack Finds Correct Stack ───────────────────────── func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) { - // When multiple stacks share the same trunk, ContinueApply should use - // StackIndex to find the right stack, not just trunk name matching. + // Stack composition, not a stale catalog index or shared trunk, identifies + // the stack being continued. gitDir := t.TempDir() // Stack 0: main <- X (a different stack) @@ -902,20 +1090,22 @@ func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) { state := &StateFile{ SchemaVersion: 1, StackName: "main", - StackIndex: 1, // The correct stack is at index 1 + StackIndex: 0, // Stale index now points at the unrelated stack Phase: PhaseConflict, ConflictBranch: "A", ConflictType: "rebase", RemainingBranches: []string{"B", "C"}, OriginalRefs: map[string]string{"B": "sha-A", "C": "sha-B"}, } + state.RecordStack(&sf.Stacks[1]) require.NoError(t, SaveState(gitDir, state)) mock := newApplyMock(gitDir, map[string]string{ "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", }) - mock.IsRebaseInProgressFn = func() (bool, error) { return true, nil } - mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil } + inProgress := true + mock.IsRebaseInProgressFn = func() (bool, error) { return inProgress, nil } + mock.RebaseContinueFn = func(opts git.RebaseOpts) error { inProgress = false; return nil } var rebasedBranches []string mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { @@ -983,12 +1173,15 @@ func TestUnwind(t *testing.T) { // Simulate partial apply: modify the stack sf.Stacks[0].Branches = []stack.BranchRef{{Branch: "A"}} // B was removed + stateFile.RecordStack(&sf.Stacks[0]) + require.NoError(t, SaveState(gitDir, stateFile)) var resetCalls []struct{ branch, sha string } var checkoutCalls []string currentBranch := "A" mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, IsRebaseInProgressFn: func() (bool, error) { return false, nil }, BranchExistsFn: func(name string) (bool, error) { return true, nil }, CheckoutBranchFn: func(name string) error { @@ -1162,19 +1355,22 @@ func TestContinueApply(t *testing.T) { "C": "sha-C", }, } + stateFile.RecordStack(&s) require.NoError(t, SaveState(gitDir, stateFile)) var rebaseContinueCalled bool var rebaseCalls []rebaseCall var checkoutCalls []string + inProgress := true mock := &git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, CurrentBranchFn: func() (string, error) { return "B", nil }, BranchExistsFn: func(string) (bool, error) { return true, nil }, - IsRebaseInProgressFn: func() (bool, error) { return true, nil }, + IsRebaseInProgressFn: func() (bool, error) { return inProgress, nil }, RebaseContinueFn: func(git.RebaseOpts) error { rebaseContinueCalled = true + inProgress = false return nil }, RebaseOntoFn: func(newBase, oldBase, branch string, opts git.RebaseOpts) error { @@ -1283,10 +1479,13 @@ func TestUnwind_AbortsActiveRebase(t *testing.T) { })) var rebaseAbortCalled bool + inProgress := true mock := &git.MockOps{ - IsRebaseInProgressFn: func() (bool, error) { return true, nil }, + GitDirFn: func() (string, error) { return gitDir, nil }, + IsRebaseInProgressFn: func() (bool, error) { return inProgress, nil }, RebaseAbortFn: func() error { rebaseAbortCalled = true + inProgress = false return nil }, BranchExistsFn: func(string) (bool, error) { return true, nil }, @@ -1338,11 +1537,13 @@ func TestUnwind_AbortsActiveCherryPick(t *testing.T) { var cherryPickAbortCalled bool var rebaseAbortCalled bool + inProgress := true mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, IsRebaseInProgressFn: func() (bool, error) { return false, nil }, - IsCherryPickInProgressFn: func() (bool, error) { return true, nil }, + IsCherryPickInProgressFn: func() (bool, error) { return inProgress, nil }, RebaseAbortFn: func() error { rebaseAbortCalled = true; return nil }, - CherryPickAbortFn: func() error { cherryPickAbortCalled = true; return nil }, + CherryPickAbortFn: func() error { cherryPickAbortCalled = true; inProgress = false; return nil }, BranchExistsFn: func(string) (bool, error) { return true, nil }, CheckoutBranchFn: func(string) error { return nil }, ResetHardFn: func(string) error { return nil }, @@ -1400,13 +1601,16 @@ func TestContinueApply_SubsequentConflictBecomesRebase(t *testing.T) { OriginalBranch: "A", OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, } + state.RecordStack(&s) require.NoError(t, SaveState(gitDir, state)) mock := newApplyMock(gitDir, map[string]string{ "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", }) // The user resolved the cherry-pick; --continue finishes it cleanly. - mock.CherryPickContinueFn = func() error { return nil } + picking := true + mock.IsCherryPickInProgressFn = func() (bool, error) { return picking, nil } + mock.CherryPickContinueFn = func() error { picking = false; return nil } // A rebases cleanly onto main; C then conflicts. mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { if branch == "C" { @@ -1468,20 +1672,25 @@ func TestContinueApply_FoldThenCascadeConflict_DoesNotResurrectFoldedBranch(t *t OriginalBranch: "A", OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, } + state.RecordStack(&s) require.NoError(t, SaveState(gitDir, state)) mock := newApplyMock(gitDir, map[string]string{ "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", }) - mock.CherryPickContinueFn = func() error { return nil } - mock.IsRebaseInProgressFn = func() (bool, error) { return true, nil } - mock.RebaseContinueFn = func(git.RebaseOpts) error { return nil } + picking := true + mock.IsCherryPickInProgressFn = func() (bool, error) { return picking, nil } + mock.CherryPickContinueFn = func() error { picking = false; return nil } + inProgress := false + mock.IsRebaseInProgressFn = func() (bool, error) { return inProgress, nil } + mock.RebaseContinueFn = func(git.RebaseOpts) error { inProgress = false; return nil } // C conflicts on its first rebase attempt, then succeeds (user resolved it). cRebases := 0 mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { if branch == "C" { cRebases++ if cRebases == 1 { + inProgress = true return assert.AnError } } @@ -1548,14 +1757,18 @@ func TestContinueApply_RebaseStartErrorPersistsRetryState(t *testing.T) { OriginalBranch: "A", OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, } + state.RecordStack(&s) require.NoError(t, SaveState(gitDir, state)) mock := newApplyMock(gitDir, map[string]string{ "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", }) cherryPickContinues := 0 + picking := true + mock.IsCherryPickInProgressFn = func() (bool, error) { return picking, nil } mock.CherryPickContinueFn = func() error { cherryPickContinues++ + picking = false return nil } cRebases := 0 @@ -1622,6 +1835,7 @@ func TestUnwind_RestoresRenamedBranch(t *testing.T) { // Simulate: A was renamed to new-A, so A no longer exists var createdBranches []struct{ name, sha string } mock := &git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, IsRebaseInProgressFn: func() (bool, error) { return false, nil }, BranchExistsFn: func(name string) (bool, error) { return name != "A", nil // A was renamed away @@ -2295,7 +2509,7 @@ func TestApplyPlan_Insert(t *testing.T) { // Branch should have been created require.Len(t, createCalls, 1) assert.Equal(t, "new-branch", createCalls[0].name) - assert.Equal(t, "A", createCalls[0].base) + assert.Equal(t, "sha-A", createCalls[0].base) // Stack should now have 3 branches: A, new-branch, B require.Len(t, sf.Stacks[0].Branches, 3) @@ -2361,7 +2575,7 @@ func TestApplyPlan_InsertAtStart(t *testing.T) { // Branch should be created from trunk require.Len(t, createCalls, 1) assert.Equal(t, "new-branch", createCalls[0].name) - assert.Equal(t, "main", createCalls[0].base) + assert.Equal(t, "sha-main", createCalls[0].base) // Stack should now have 3 branches: new-branch, A, B require.Len(t, sf.Stacks[0].Branches, 3) @@ -2419,3 +2633,587 @@ func TestApplyPlan_InsertAffectsPRs(t *testing.T) { // Should need submit because insertion changes the base of a branch with PR assert.True(t, result.NeedsSubmit, "inserting before a branch with a PR should trigger NeedsSubmit") } + +func TestMatchesStack(t *testing.T) { + original := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, {Branch: "B"}, + }, + } + metadata, err := json.Marshal(original) + require.NoError(t, err) + renamed := original + renamed.Branches = []stack.BranchRef{{Branch: "new-A"}, {Branch: "B"}} + other := stack.Stack{Trunk: original.Trunk, Branches: []stack.BranchRef{{Branch: "X"}}} + tests := []struct { + name string + state *StateFile + target stack.Stack + want bool + }{ + {"legacy snapshot", &StateFile{Snapshot: Snapshot{StackMetadata: metadata}}, original, true}, + {"legacy renamed snapshot", &StateFile{ + Snapshot: Snapshot{StackMetadata: metadata}, + Plan: []Action{{Type: "rename", Branch: "A", NewName: "new-A"}}, + }, renamed, true}, + {"index and trunk are not identity", &StateFile{StackIndex: 0, StackName: "main"}, other, false}, + {"same trunk different stack", &StateFile{Snapshot: Snapshot{StackMetadata: metadata}}, other, false}, + {"conflicting remote identity", &StateFile{PriorRemoteStackID: "first"}, stack.Stack{ID: "second"}, false}, + {"matching remote identity", &StateFile{PriorRemoteStackID: "first"}, stack.Stack{ID: "first"}, true}, + {"nil state", nil, original, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, MatchesStack(tt.state, &tt.target)) + }) + } + t.Run("catalog publication boundary", func(t *testing.T) { + state := &StateFile{Phase: PhaseApplying} + state.RecordStack(&original) + state.RecordStack(&renamed) + assert.True(t, MatchesStack(state, &original)) + assert.True(t, MatchesStack(state, &renamed)) + state.Phase = PhasePendingSubmit + assert.False(t, MatchesStack(state, &original), "submit must match only the completed composition") + assert.True(t, MatchesStack(state, &renamed)) + }) +} + +func TestContinueApply_RejectsUnidentifiableStack(t *testing.T) { + dir := t.TempDir() + target := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}}} + other := stack.Stack{Trunk: target.Trunk, Branches: []stack.BranchRef{{Branch: "B"}}} + writeTestStackFile(t, dir, other) + state := &StateFile{SchemaVersion: 1, Phase: PhaseConflict, StackIndex: 0, ConflictBranch: "A"} + state.RecordStack(&target) + require.NoError(t, SaveState(dir, state)) + called := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return dir, nil }, + RebaseContinueFn: func(git.RebaseOpts) error { called = true; return nil }, + }) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.ErrorContains(t, ContinueApply(cfg, dir, noopUpdateBaseSHAs), "recorded by modify was not found") + assert.False(t, called) + assert.True(t, StateExists(dir)) +} + +func TestContinueApply_RejectsUnpublishedCatalogChange(t *testing.T) { + dir := t.TempDir() + original := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + } + modified := original + modified.Branches = []stack.BranchRef{{Branch: "B"}} + writeTestStackFile(t, dir, original) + state := &StateFile{SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "rebase", ConflictBranch: "B"} + state.RecordStack(&original) + state.RecordStack(&modified) + require.NoError(t, SaveState(dir, state)) + called := false + restore := git.SetOps(&git.MockOps{ + RebaseContinueFn: func(git.RebaseOpts) error { called = true; return nil }, + }) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.ErrorContains(t, ContinueApply(cfg, dir, noopUpdateBaseSHAs), "catalog update did not complete") + assert.False(t, called, "continuation must not resurrect a branch omitted by an unpublished catalog update") + assert.True(t, StateExists(dir)) +} + +func TestContinueApply_LegacyInsertedBranchCanAbort(t *testing.T) { + dir := t.TempDir() + original := stack.Stack{ + ID: "remote-stack", Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + } + modified := original + modified.Branches = []stack.BranchRef{{Branch: "A"}, {Branch: "inserted"}, {Branch: "B"}} + writeTestStackFile(t, dir, modified) + metadata, err := json.Marshal(original) + require.NoError(t, err) + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "rebase", ConflictBranch: "inserted", + PriorRemoteStackID: original.ID, OriginalBranch: "A", RemainingBranches: []string{"B"}, + OriginalRefs: map[string]string{"B": "sha-A"}, + Snapshot: Snapshot{ + StackMetadata: metadata, + Branches: []BranchSnapshot{{Name: "A", TipSHA: "sha-A"}, {Name: "B", TipSHA: "sha-B"}}, + }, + Plan: []Action{{Type: "insert_below", Branch: "inserted", NewName: "inserted", NewPosition: 1}}, + } + require.NoError(t, SaveState(dir, state)) + refs := map[string]string{"main": "sha-main", "A": "sha-A", "B": "sha-B", "inserted": "sha-inserted"} + mock := newApplyMock(dir, refs) + inProgress := true + mock.IsRebaseInProgressFn = func() (bool, error) { return inProgress, nil } + mock.RebaseContinueFn = func(git.RebaseOpts) error { inProgress = false; return nil } + mock.RebaseOntoFn = func(string, string, string, git.RebaseOpts) error { + inProgress = true + return assert.AnError + } + mock.RebaseAbortFn = func() error { inProgress = false; return nil } + mock.DeleteBranchFn = func(name string, _ bool) error { delete(refs, name); return nil } + restore := git.SetOps(mock) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.Error(t, ContinueApply(cfg, dir, noopUpdateBaseSHAs)) + require.NoError(t, UnwindFromStateFile(cfg, dir)) + exists, err := mock.BranchExists("inserted") + require.NoError(t, err) + assert.False(t, exists) + assert.False(t, StateExists(dir)) + saved, err := stack.Load(dir) + require.NoError(t, err) + assert.Equal(t, []string{"A", "B"}, saved.Stacks[0].BranchNames()) +} + +func TestApplyPlan_RenameFailureUnwindsWithoutNestedLock(t *testing.T) { + dir := t.TempDir() + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}}} + sf := writeTestStackFile(t, dir, s) + mock := newApplyMock(dir, map[string]string{"main": "base", "A": "original"}) + mock.RenameBranchFn = func(string, string) error { + lock, err := stack.Lock(dir) + require.NoError(t, err, "Git mutations must not hold the catalog lock") + lock.Unlock() + return assert.AnError + } + restore := git.SetOps(mock) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-A"} + _, _, err := ApplyPlan(cfg, dir, &sf.Stacks[0], sf, nodes, "A", noopUpdateBaseSHAs) + require.ErrorIs(t, err, assert.AnError) + assert.False(t, StateExists(dir), "successful unwind should clear the recovery journal") +} + +func TestUnwind_PartialFailureRetainsState(t *testing.T) { + for _, failure := range []string{"abort", "reset", "catalog"} { + t.Run(failure, func(t *testing.T) { + dir := t.TempDir() + origin := t.TempDir() + s := stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "A"}}} + writeTestStackFile(t, dir, s) + metadata, err := json.Marshal(s) + require.NoError(t, err) + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "rebase", OriginalBranch: "A", + Snapshot: Snapshot{ + StackMetadata: metadata, + Branches: []BranchSnapshot{{Name: "A", TipSHA: "original"}}, + }, + Worktrees: &worktree.Context{ + Origin: worktree.Location{Path: origin}, + Touched: map[string]string{"A": "changed"}, + }, + } + state.RecordStack(&s) + require.NoError(t, SaveState(dir, state)) + sha := "changed" + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return dir, nil }, + RootDirFn: func() (string, error) { return origin, nil }, + CurrentBranchFn: func() (string, error) { return "A", nil }, + RevParseFn: func(string) (string, error) { return sha, nil }, + IsRebaseInProgressFn: func() (bool, error) { return failure == "abort", nil }, + RebaseAbortFn: func() error { return assert.AnError }, + ResetHardFn: func(value string) error { + if failure == "reset" { + return assert.AnError + } + sha = value + external, err := stack.Load(dir) + require.NoError(t, err) + external.Stacks = append(external.Stacks, stack.Stack{ + Trunk: s.Trunk, Branches: []stack.BranchRef{{Branch: "other"}}, + }) + require.NoError(t, stack.Save(dir, external)) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + require.Error(t, UnwindFromStateFile(cfg, dir)) + saved, err := LoadState(dir) + require.NoError(t, err) + require.NotNil(t, saved) + assert.Equal(t, PhaseApplying, saved.Phase) + if failure == "catalog" { + external, err := stack.Load(dir) + require.NoError(t, err) + assert.Len(t, external.Stacks, 2, "stale recovery must not overwrite another catalog writer") + } + }) + } +} + +func runModifyGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + switch key { + case "GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE": + continue + } + cmd.Env = append(cmd.Env, entry) + } + output, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, output) + return strings.TrimSpace(string(output)) +} + +func setupModifyWorktrees(t *testing.T, conflicting bool, initOptions ...string) (root, origin, caller, common string, sf *stack.StackFile) { + t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_AUTHOR_NAME", "Modify Test") + t.Setenv("GIT_AUTHOR_EMAIL", "modify@example.com") + t.Setenv("GIT_COMMITTER_NAME", "Modify Test") + t.Setenv("GIT_COMMITTER_EMAIL", "modify@example.com") + t.Setenv("GIT_EDITOR", "true") + dir := t.TempDir() + root, origin, caller = filepath.Join(dir, "repo"), filepath.Join(dir, "modify worktree"), filepath.Join(dir, "caller") + require.NoError(t, os.Mkdir(root, 0755)) + runModifyGit(t, root, append([]string{"init", "-q", "-b", "main"}, initOptions...)...) + writeCommit := func(file, content string) { + require.NoError(t, os.WriteFile(filepath.Join(root, file), []byte(content), 0644)) + runModifyGit(t, root, "add", file) + runModifyGit(t, root, "commit", "-qm", content) + } + writeCommit("base.txt", "base\n") + runModifyGit(t, root, "checkout", "-qb", "A") + if conflicting { + writeCommit("base.txt", "A\n") + } else { + writeCommit("a.txt", "A\n") + } + runModifyGit(t, root, "checkout", "-qb", "B") + if conflicting { + writeCommit("base.txt", "B\n") + } else { + writeCommit("b.txt", "B\n") + } + runModifyGit(t, root, "checkout", "-q", "main") + runModifyGit(t, root, "worktree", "add", "-q", origin, "B") + runModifyGit(t, root, "worktree", "add", "-q", "-b", "observer", caller, "main") + common = runModifyGit(t, root, "rev-parse", "--path-format=absolute", "--git-common-dir") + sf = writeTestStackFile(t, common, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "A"}, {Branch: "B"}}, + }) + // Bootstrap an executor for this test repository without changing cwd. + // ForWorktree then strips these variables and binds its own Git context. + t.Setenv("GIT_DIR", common) + t.Setenv("GIT_WORK_TREE", root) + return +} + +func TestApplyPlan_SeparateGitDirOrigin(t *testing.T) { + for _, location := range []string{"main", "linked"} { + t.Run(location, func(t *testing.T) { + root, linked, caller, common, sf := setupModifyWorktrees(t, false, "--separate-git-dir", t.TempDir()) + origin := linked + if location == "main" { + runModifyGit(t, linked, "checkout", "-q", "--detach") + runModifyGit(t, root, "checkout", "-q", "B") + origin = root + } + restore := git.SetOps(requireWorktree(t, git.CurrentOps(), origin)) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-B"} + _, conflict, err := ApplyPlan(cfg, common, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err, "the invoking worktree is known even when Git's main-owner path is not discoverable remotely") + assert.Nil(t, conflict) + assert.Equal(t, "new-B", runModifyGit(t, origin, "branch", "--show-current")) + assert.Equal(t, "observer", runModifyGit(t, caller, "branch", "--show-current")) + assert.False(t, StateExists(common)) + }) + } +} + +func TestApplyPlan_LinkedWorktreeWithForeignTrunk(t *testing.T) { + root, origin, caller, common, sf := setupModifyWorktrees(t, false) + require.NoError(t, os.WriteFile(filepath.Join(caller, "note.txt"), []byte("keep"), 0644)) + restore := git.SetOps(requireWorktree(t, git.CurrentOps(), origin)) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + result, conflict, err := ApplyPlan(cfg, common, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.NoError(t, err) + require.NotNil(t, result) + assert.Nil(t, conflict) + assert.Equal(t, []string{"B"}, sf.Stacks[0].BranchNames()) + assert.Equal(t, "B", runModifyGit(t, origin, "branch", "--show-current")) + assert.Equal(t, "main", runModifyGit(t, root, "branch", "--show-current")) + assert.Equal(t, "observer", runModifyGit(t, caller, "branch", "--show-current")) + assert.Equal(t, "?? note.txt", runModifyGit(t, caller, "status", "--porcelain")) + assert.False(t, StateExists(common)) +} + +func TestApplyPlan_DistributedStackRejectedBeforeMutation(t *testing.T) { + root, origin, _, common, sf := setupModifyWorktrees(t, false) + owner := filepath.Join(filepath.Dir(origin), "A owner") + runModifyGit(t, root, "worktree", "add", "-q", owner, "A") + before, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + original := runModifyGit(t, root, "rev-parse", "B") + restore := git.SetOps(requireWorktree(t, git.CurrentOps(), origin)) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-B"} + _, _, err = ApplyPlan(cfg, common, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.ErrorContains(t, err, "distributed modify is not supported yet") + assert.Contains(t, err.Error(), "A owner") + after, err := os.ReadFile(filepath.Join(common, "gh-stack")) + require.NoError(t, err) + assert.Equal(t, before, after) + assert.Equal(t, original, runModifyGit(t, root, "rev-parse", "B")) + assert.Equal(t, "B", runModifyGit(t, origin, "branch", "--show-current")) + assert.False(t, StateExists(common)) +} + +func TestModifyRecovery_FromAnotherWorktree(t *testing.T) { + for _, name := range []string{"continue", "abort", "abort renamed origin"} { + t.Run(name, func(t *testing.T) { + root, origin, caller, common, sf := setupModifyWorktrees(t, true) + cwd, err := os.Getwd() + require.NoError(t, err) + originalB := runModifyGit(t, root, "rev-parse", "B") + observer := runModifyGit(t, caller, "rev-parse", "HEAD") + require.NoError(t, os.WriteFile(filepath.Join(caller, "note.txt"), []byte("keep"), 0644)) + originOps := requireWorktree(t, git.CurrentOps(), origin) + restore := git.SetOps(originOps) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + if name == "abort renamed origin" { + nodes[1].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionRename, NewName: "new-B"} + } + _, conflict, err := ApplyPlan(cfg, common, &sf.Stacks[0], sf, nodes, "B", noopUpdateBaseSHAs) + require.Error(t, err) + require.NotNil(t, conflict) + state, err := LoadState(common) + require.NoError(t, err) + require.NotNil(t, state.Worktrees) + assert.True(t, worktree.SamePath(origin, state.Worktrees.Origin.Path)) + assert.Equal(t, "B", state.OriginalBranch) + assert.True(t, requireGitState(t, originOps.IsRebaseInProgress)) + callerOps := requireWorktree(t, originOps, caller) + assert.False(t, requireGitState(t, callerOps.IsRebaseInProgress)) + restoreCaller := git.SetOps(callerOps) + defer restoreCaller() + if name == "continue" { + require.NoError(t, os.WriteFile(filepath.Join(origin, "base.txt"), []byte("resolved\n"), 0644)) + runModifyGit(t, origin, "add", "base.txt") + require.NoError(t, ContinueApply(cfg, common, noopUpdateBaseSHAs)) + } else { + require.NoError(t, UnwindFromStateFile(cfg, common)) + assert.Equal(t, originalB, runModifyGit(t, origin, "rev-parse", "B")) + } + assert.False(t, StateExists(common)) + assert.False(t, requireGitState(t, originOps.IsRebaseInProgress)) + assert.Equal(t, "B", runModifyGit(t, origin, "branch", "--show-current")) + assert.Equal(t, "", runModifyGit(t, origin, "status", "--porcelain")) + assert.Equal(t, "main", runModifyGit(t, root, "branch", "--show-current")) + assert.Equal(t, observer, runModifyGit(t, caller, "rev-parse", "HEAD")) + assert.Equal(t, "?? note.txt", runModifyGit(t, caller, "status", "--porcelain")) + afterCwd, err := os.Getwd() + require.NoError(t, err) + assert.Equal(t, cwd, afterCwd) + recovered, err := stack.Load(common) + require.NoError(t, err) + if name == "continue" { + assert.Equal(t, []string{"B"}, recovered.Stacks[0].BranchNames()) + } else { + assert.Equal(t, []string{"A", "B"}, recovered.Stacks[0].BranchNames()) + } + }) + } +} + +func TestModifyRecovery_PreservesExternalCommitAfterSaveFailure(t *testing.T) { + _, origin, caller, common, sf := setupModifyWorktrees(t, false) + restore := git.SetOps(requireWorktree(t, git.CurrentOps(), origin)) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + nodes := makeNodes(&sf.Stacks[0]) + nodes[0].PendingAction = &modifyview.PendingAction{Type: modifyview.ActionDrop} + nodes[0].Removed = true + _, _, err := ApplyPlan(cfg, common, &sf.Stacks[0], sf, nodes, "B", func(*stack.Stack) { + external, loadErr := stack.Load(common) + require.NoError(t, loadErr) + external.Stacks = append(external.Stacks, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "observer"}}, + }) + require.NoError(t, stack.Save(common, external)) + }) + var stale *stack.StaleError + require.ErrorAs(t, err, &stale) + state, err := LoadState(common) + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, PhaseApplying, state.Phase) + require.NoError(t, os.WriteFile(filepath.Join(origin, "external.txt"), []byte("keep this commit\n"), 0644)) + runModifyGit(t, origin, "add", "external.txt") + runModifyGit(t, origin, "commit", "-qm", "external change") + externalTip := runModifyGit(t, origin, "rev-parse", "B") + restoreCaller := git.SetOps(requireWorktree(t, git.CurrentOps(), caller)) + defer restoreCaller() + require.ErrorContains(t, UnwindFromStateFile(cfg, common), "changed after this operation") + assert.Equal(t, externalTip, runModifyGit(t, origin, "rev-parse", "B")) + assert.True(t, StateExists(common)) + saved, err := stack.Load(common) + require.NoError(t, err) + assert.Len(t, saved.Stacks, 2) +} + +func TestContinueApply_PreservesExternalCommitOnRemainingBranch(t *testing.T) { + for _, legacy := range []bool{false, true} { + name := "recorded context" + if legacy { + name = "legacy journal" + } + t.Run(name, func(t *testing.T) { + dir, origin := t.TempDir(), t.TempDir() + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "B"}, {Branch: "C"}, + }, + } + writeTestStackFile(t, dir, s) + metadata, err := json.Marshal(s) + require.NoError(t, err) + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "rebase", ConflictBranch: "B", + OriginalBranch: "B", RemainingBranches: []string{"C"}, + OriginalRefs: map[string]string{"C": "B-original"}, + Snapshot: Snapshot{ + StackMetadata: metadata, + Branches: []BranchSnapshot{ + {Name: "B", TipSHA: "B-original"}, {Name: "C", TipSHA: "C-original"}, + }, + }, + } + if !legacy { + state.Worktrees = &worktree.Context{Origin: worktree.Location{Path: origin}} + } + state.RecordStack(&s) + require.NoError(t, SaveState(dir, state)) + refs := map[string]string{"B": "B-original", "C": "C-external-commit"} + inProgress := true + var started, reset []string + mock := newApplyMock(dir, refs) + mock.RootDirFn = func() (string, error) { return origin, nil } + mock.CurrentBranchFn = func() (string, error) { return "B", nil } + mock.IsRebaseInProgressFn = func() (bool, error) { return inProgress, nil } + mock.RebaseContinueFn = func(git.RebaseOpts) error { + inProgress = false + refs["B"] = "B-rebased" + return nil + } + mock.RebaseAbortFn = func() error { inProgress = false; return nil } + mock.RebaseOntoFn = func(_, _, branch string, _ git.RebaseOpts) error { + started = append(started, branch) + refs[branch] = "overwritten" + return nil + } + mock.ResetHardFn = func(sha string) error { + reset = append(reset, "B") + refs["B"] = sha + return nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + reset = append(reset, branch) + refs[branch] = sha + return nil + } + restore := git.SetOps(mock) + defer restore() + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + require.ErrorContains(t, ContinueApply(cfg, dir, noopUpdateBaseSHAs), "C changed since") + assert.Empty(t, started, "the changed remaining branch must be rejected before starting its rebase") + saved, err := LoadState(dir) + require.NoError(t, err) + require.NotNil(t, saved) + if legacy { + assert.Nil(t, saved.Worktrees, "legacy adoption must not claim an external change") + require.ErrorContains(t, UnwindFromStateFile(cfg, dir), "C changed since") + assert.Empty(t, reset) + assert.True(t, inProgress, "ambiguous legacy recovery must stop before aborting Git") + assert.True(t, StateExists(dir)) + } else { + assert.NotContains(t, saved.Worktrees.Touched, "C") + assert.Empty(t, saved.Worktrees.Pending) + require.NoError(t, UnwindFromStateFile(cfg, dir)) + assert.Equal(t, []string{"B"}, reset) + assert.False(t, StateExists(dir)) + } + assert.Equal(t, "B-original", refs["B"]) + assert.Equal(t, "C-external-commit", refs["C"]) + }) + } +} + +func TestStartRefMutation_UsesRecordedExpectedTip(t *testing.T) { + for _, touched := range []bool{false, true} { + name, expected := "original snapshot", "original" + if touched { + name, expected = "previously modified branch", "last-written" + } + t.Run(name, func(t *testing.T) { + dir, origin := t.TempDir(), t.TempDir() + state := &StateFile{ + SchemaVersion: 1, Phase: PhaseApplying, + Snapshot: Snapshot{Branches: []BranchSnapshot{{Name: "A", TipSHA: "original"}}}, + Worktrees: &worktree.Context{Origin: worktree.Location{Path: origin}}, + } + if touched { + state.Worktrees.Touched = map[string]string{"A": expected} + } + mock := newApplyMock(dir, map[string]string{"A": expected}) + restore := git.SetOps(mock) + defer restore() + _, err := startRefMutation(dir, state, "A") + require.NoError(t, err) + assert.Equal(t, "A", state.Worktrees.Pending) + assert.Equal(t, expected, state.Worktrees.PendingBefore) + }) + } +} diff --git a/internal/modify/preconditions.go b/internal/modify/preconditions.go index 40eabe7..6d1ddc2 100644 --- a/internal/modify/preconditions.go +++ b/internal/modify/preconditions.go @@ -6,8 +6,49 @@ import ( "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" ) +// CheckWorktrees permits modify in a linked worktree, but not a stack whose +// member branches are checked out in other worktrees. Trunk is only read. +func CheckWorktrees(s *stack.Stack) (*worktree.Context, error) { + ctx, err := worktree.New() + if err != nil { + return nil, err + } + if err := checkSingleWorktree(ctx, s.BranchNames()); err != nil { + return nil, err + } + ops, err := ctx.OriginOps() + if err != nil { + return nil, err + } + if err := worktree.CheckClean(ops, ctx.Origin.Path); err != nil { + return nil, err + } + return ctx, nil +} + +func checkSingleWorktree(ctx *worktree.Context, branches []string) error { + if _, err := ctx.OriginOps(); err != nil { + return err + } + trees, err := git.Worktrees() + if err != nil { + return fmt.Errorf("checking modify worktree ownership: %w", err) + } + members := make(map[string]bool, len(branches)) + for _, name := range branches { + members[name] = true + } + for _, tree := range trees { + if tree.Branch != "" && members[tree.Branch] && !worktree.SamePath(tree.Path, ctx.Origin.Path) { + return fmt.Errorf("distributed modify is not supported yet: branch %s is checked out in worktree %s; all stack branches must be unoccupied or in %s", tree.Branch, tree.Path, ctx.Origin.Path) + } + } + return nil +} + // CheckNoMergeQueuePRs checks that no unmerged PR in the stack is currently queued. func CheckNoMergeQueuePRs(cfg *config.Config, s *stack.Stack) error { for _, b := range s.Branches { @@ -56,7 +97,8 @@ func CheckStackLinearity(cfg *config.Config, s *stack.Stack) error { merges, err := git.LogMerges(parentBranch, b.Branch) if err != nil { - continue + cfg.Errorf("failed to check merge commits for %s: %s", b.Branch, err) + return fmt.Errorf("checking merge commits for %s: %w", b.Branch, err) } if len(merges) > 0 { cfg.Errorf("%s contains a merge commit — modify requires linear history", b.Branch) diff --git a/internal/modify/recovery.go b/internal/modify/recovery.go new file mode 100644 index 0000000..b847ad0 --- /dev/null +++ b/internal/modify/recovery.go @@ -0,0 +1,473 @@ +package modify + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" +) + +func findStack(state *StateFile, sf *stack.StackFile) (*stack.Stack, error) { + var target *stack.Stack + for i := range sf.Stacks { + if MatchesStack(state, &sf.Stacks[i]) { + if target != nil { + return nil, fmt.Errorf("modify recovery matches multiple stacks; recovery state was retained") + } + target = &sf.Stacks[i] + } + } + if target == nil { + return nil, fmt.Errorf("the stack recorded by modify was not found; recovery state was retained") + } + return target, nil +} + +func recoveryBranches(state *StateFile) []string { + names := append([]string{}, state.StackBranches...) + for _, branch := range state.Snapshot.Branches { + names = append(names, branch.Name) + } + for _, action := range state.Plan { + names = append(names, action.Branch) + if action.NewName != "" { + names = append(names, action.NewName) + } + } + return names +} + +func recoveryBranchAvailability(state *StateFile, ops git.Ops) (map[string]bool, error) { + names := recoveryBranches(state) + for oldName, newName := range state.RenamedBranches { + names = append(names, oldName, newName) + } + for name := range state.CreatedBranches { + names = append(names, name) + } + if state.PendingAction != nil { + names = append(names, state.PendingAction.Branch, state.PendingAction.NewName) + } + existing := make(map[string]bool, len(names)) + for _, name := range names { + if name == "" { + continue + } + if _, checked := existing[name]; checked { + continue + } + exists, err := ops.BranchExists(name) + if err != nil { + return nil, fmt.Errorf("checking branch %s before modify recovery: %w", name, err) + } + existing[name] = exists + } + return existing, nil +} + +func recoveryContext(dir string, state *StateFile) (*worktree.Context, error) { + ctx := state.Worktrees + if ctx == nil { + var err error + ctx, err = worktree.New() + if err != nil { + return nil, err + } + ops, err := ctx.OriginOps() + if err != nil { + return nil, err + } + nativeDir, err := ops.GitDir() + if err != nil { + return nil, err + } + if !worktree.SamePath(nativeDir, dir) { + return nil, fmt.Errorf("legacy modify recovery must run in its original worktree before shared-state migration") + } + } + if err := checkSingleWorktree(ctx, recoveryBranches(state)); err != nil { + return nil, err + } + return ctx, nil +} + +func adoptLegacyContext(state *StateFile, ctx *worktree.Context, ops git.Ops, existing map[string]bool) error { + if err := checkLegacyRemainingRefs(state, ops); err != nil { + return err + } + for _, branch := range state.Snapshot.Branches { + name := branch.Name + for _, action := range state.Plan { + if action.Type == "rename" && action.Branch == name && !existing[name] && existing[action.NewName] { + if state.RenamedBranches == nil { + state.RenamedBranches = make(map[string]string) + } + state.RenamedBranches[name] = action.NewName + ctx.Rename(name, action.NewName) + name = action.NewName + } + } + sha, err := ops.RevParse(name) + if err != nil { + return fmt.Errorf("reading legacy recovery branch %s: %w", name, err) + } + if sha != branch.TipSHA || name != branch.Name { + ctx.Touched[name] = sha + } + } + for _, action := range state.Plan { + if action.Type != "insert_below" && action.Type != "insert_above" { + continue + } + if !existing[action.NewName] { + return fmt.Errorf("legacy inserted branch %s is missing; recovery state was retained", action.NewName) + } + sha, err := ops.RevParse(action.NewName) + if err != nil { + return fmt.Errorf("reading legacy inserted branch %s: %w", action.NewName, err) + } + if state.CreatedBranches == nil { + state.CreatedBranches = make(map[string]string) + } + state.CreatedBranches[action.NewName] = sha + } + state.Worktrees = ctx + return nil +} + +// Do not seed Touched from a future branch's changed tip: that would authorize +// abort to discard a commit made by someone else while the operation was paused. +func checkLegacyRemainingRefs(state *StateFile, ops git.Ops) error { + expected := originalTips(state) + for _, action := range state.Plan { + if action.Type == "rename" { + if sha, ok := expected[action.Branch]; ok { + expected[action.NewName] = sha + delete(expected, action.Branch) + } + } + } + remaining := append([]string{}, state.RemainingBranches...) + active := state.ConflictBranch + if state.ConflictType == "cherry_pick" { + active = state.FoldTarget + } else if state.ConflictType == "rebase_start" { + remaining = append(remaining, state.ConflictBranch) + active = "" + } + for _, branch := range remaining { + before, known := expected[branch] + if !known || branch == active { + continue + } + current, err := ops.RevParse(branch) + if err != nil { + return fmt.Errorf("checking legacy remaining branch %s: %w", branch, err) + } + if current != before { + return fmt.Errorf("remaining branch %s changed since the legacy snapshot; cannot safely attribute that change to modify, recovery state was retained", branch) + } + } + return nil +} + +// Publish the recovery identity before the catalog so an interrupted write +// remains identifiable on either side of the short catalog save. +func saveProgress(dir string, state *StateFile, s *stack.Stack, sf *stack.StackFile) error { + state.RecordStack(s) + if err := SaveState(dir, state); err != nil { + return fmt.Errorf("saving modify recovery state: %w", err) + } + if err := stack.Save(dir, sf); err != nil { + return fmt.Errorf("saving stack metadata (modify recovery state retained): %w", err) + } + return nil +} + +func originalTips(state *StateFile) map[string]string { + refs := make(map[string]string, len(state.Snapshot.Branches)+len(state.CreatedBranches)) + for _, branch := range state.Snapshot.Branches { + refs[branch.Name] = branch.TipSHA + } + for name, sha := range state.CreatedBranches { + refs[name] = sha + } + return refs +} + +func prepareMutation(state *StateFile) (git.Ops, error) { + if err := checkSingleWorktree(state.Worktrees, recoveryBranches(state)); err != nil { + return nil, err + } + ops, err := state.Worktrees.OriginOps() + if err != nil { + return nil, err + } + if err := worktree.CheckClean(ops, state.Worktrees.Origin.Path); err != nil { + return nil, err + } + return ops, nil +} + +func startRefMutation(dir string, state *StateFile, branch string) (git.Ops, error) { + ops, err := prepareMutation(state) + if err != nil { + return nil, err + } + expected := state.Worktrees.Touched[branch] + if expected == "" { + expected = originalTips(state)[branch] + } + if expected == "" { + err = state.Worktrees.Start(branch) + } else { + err = state.Worktrees.Start(branch, expected) + } + if err != nil { + return nil, err + } + if err := SaveState(dir, state); err != nil { + return nil, err + } + return ops, nil +} + +func finishApply(dir string, state *StateFile, s *stack.Stack, sf *stack.StackFile, needsSubmit bool) error { + state.Phase = PhaseApplying + state.ConflictBranch, state.ConflictType = "", "" + state.RemainingBranches = nil + if err := saveProgress(dir, state, s, sf); err != nil { + return err + } + if needsSubmit { + state.Phase = PhasePendingSubmit + state.PreviousStackBranches = nil + state.RecordStack(s) + return SaveState(dir, state) + } + return ClearState(dir) +} + +func unwindState(cfg *config.Config, dir string, state *StateFile, sf *stack.StackFile) error { + if state.Phase != PhaseApplying && state.Phase != PhaseConflict { + return fmt.Errorf("cannot unwind modify in phase %q; recovery state was retained", state.Phase) + } + target, err := findStack(state, sf) + if err != nil { + return err + } + var restored stack.Stack + if err := json.Unmarshal(state.Snapshot.StackMetadata, &restored); err != nil { + return fmt.Errorf("restoring stack metadata: %w", err) + } + if restored.Trunk.Branch == "" { + return fmt.Errorf("modify snapshot has no stack identity; recovery state was retained") + } + ctx, err := recoveryContext(dir, state) + if err != nil { + return err + } + ops, err := ctx.OriginOps() + if err != nil { + return err + } + if state.Worktrees == nil { + if err := checkLegacyRemainingRefs(state, ops); err != nil { + return err + } + } + rebasing, err := ops.IsRebaseInProgress() + if err != nil { + return fmt.Errorf("checking rebase state before unwind: %w", err) + } + picking, err := ops.IsCherryPickInProgress() + if err != nil { + return fmt.Errorf("checking cherry-pick state before unwind: %w", err) + } + existing, err := recoveryBranchAvailability(state, ops) + if err != nil { + return err + } + state.Phase = PhaseApplying + if err := SaveState(dir, state); err != nil { + return err + } + retain := func(err error) error { + return errors.Join(err, SaveState(dir, state)) + } + + if rebasing { + if state.Worktrees != nil && state.ConflictType != "rebase" && state.ConflictType != "rebase_start" { + return retain(fmt.Errorf("an unrelated rebase is active in %s; recovery state was retained", ctx.Origin.Path)) + } + if err := ops.RebaseAbort(); err != nil { + return retain(fmt.Errorf("aborting rebase in %s: %w", ctx.Origin.Path, err)) + } + } + if picking { + if state.Worktrees != nil && state.ConflictType != "cherry_pick" { + return retain(fmt.Errorf("an unrelated cherry-pick is active in %s; recovery state was retained", ctx.Origin.Path)) + } + if err := ops.CherryPickAbort(); err != nil { + return retain(fmt.Errorf("aborting cherry-pick in %s: %w", ctx.Origin.Path, err)) + } + } + if err := worktree.CheckClean(ops, ctx.Origin.Path); err != nil { + return retain(err) + } + + originalBranch := state.OriginalBranch + if originalBranch == "" && len(state.Snapshot.Branches) > 0 { + originalBranch = state.Snapshot.Branches[0].Name + } + if state.Worktrees == nil { + if err := restoreLegacy(ops, state, originalBranch, existing); err != nil { + return retain(err) + } + } else { + if err := recoverPendingAction(state, ops, existing); err != nil { + return retain(err) + } + for i := len(state.Plan) - 1; i >= 0; i-- { + action := state.Plan[i] + newName, renamed := state.RenamedBranches[action.Branch] + if action.Type != "rename" || !renamed { + continue + } + if existing[newName] { + sha, err := ops.RevParse(newName) + if err != nil { + return retain(err) + } + if sha != ctx.Touched[newName] || existing[action.Branch] { + return retain(fmt.Errorf("renamed branch %s changed after modify; leaving it untouched", newName)) + } + if err := ops.RenameBranch(newName, action.Branch); err != nil { + return retain(fmt.Errorf("restoring branch name %s: %w", action.Branch, err)) + } + existing[newName], existing[action.Branch] = false, true + } else { + sha, err := ops.RevParse(action.Branch) + if err != nil || sha != ctx.Touched[newName] { + return retain(fmt.Errorf("cannot identify renamed branch %s; recovery state was retained", newName)) + } + } + ctx.Rename(newName, action.Branch) + delete(state.RenamedBranches, action.Branch) + if err := SaveState(dir, state); err != nil { + return err + } + } + if err := ctx.Restore(originalTips(state)); err != nil { + return retain(err) + } + if originalBranch != "" { + if err := ctx.RestoreOrigin(originalBranch); err != nil { + return retain(err) + } + } + for name, original := range state.CreatedBranches { + if existing[name] { + sha, err := ops.RevParse(name) + if err != nil || sha != original { + return retain(fmt.Errorf("inserted branch %s changed after modify; leaving it untouched", name)) + } + if err := ops.DeleteBranch(name, true); err != nil { + return retain(fmt.Errorf("removing inserted branch %s: %w", name, err)) + } + } + delete(state.CreatedBranches, name) + } + } + + *target = restored + if err := saveProgress(dir, state, target, sf); err != nil { + return err + } + if err := ClearState(dir); err != nil { + return err + } + cfg.Successf("Stack restored to pre-modify state") + return nil +} + +func recoverPendingAction(state *StateFile, ops git.Ops, existing map[string]bool) error { + action := state.PendingAction + if action == nil { + return nil + } + switch action.Type { + case "rename": + if existing[action.NewName] && !existing[action.Branch] { + sha, err := ops.RevParse(action.NewName) + if err != nil || sha != state.Worktrees.PendingBefore { + return fmt.Errorf("cannot identify interrupted rename to %s; recovery state was retained", action.NewName) + } + if state.RenamedBranches == nil { + state.RenamedBranches = make(map[string]string) + } + state.RenamedBranches[action.Branch] = action.NewName + state.Worktrees.Rename(action.Branch, action.NewName) + if err := state.Worktrees.Record(action.NewName); err != nil { + return err + } + } else if !existing[action.Branch] || existing[action.NewName] { + return fmt.Errorf("cannot identify interrupted rename of %s; recovery state was retained", action.Branch) + } + case "insert_below", "insert_above": + if existing[action.NewName] && state.CreatedBranches[action.NewName] == "" { + return fmt.Errorf("cannot prove branch %s was created by the interrupted insert; recovery state was retained", action.NewName) + } + } + state.PendingAction = nil + return nil +} + +// Old journals have no last-written refs. Their original-worktree-only +// compatibility path retains the historical snapshot restoration semantics. +func restoreLegacy(ops git.Ops, state *StateFile, originalBranch string, existing map[string]bool) error { + for i := len(state.Plan) - 1; i >= 0; i-- { + action := state.Plan[i] + if action.Type == "rename" && !existing[action.Branch] && existing[action.NewName] { + if err := ops.RenameBranch(action.NewName, action.Branch); err != nil { + return fmt.Errorf("restoring renamed branch %s: %w", action.Branch, err) + } + existing[action.NewName], existing[action.Branch] = false, true + } + } + for _, branch := range state.Snapshot.Branches { + if !existing[branch.Name] { + if err := ops.CreateBranch(branch.Name, branch.TipSHA); err != nil { + return fmt.Errorf("restoring branch %s: %w", branch.Name, err) + } + existing[branch.Name] = true + continue + } + if err := ops.CheckoutBranch(branch.Name); err != nil { + return fmt.Errorf("checking out %s for recovery: %w", branch.Name, err) + } + if err := ops.ResetHard(branch.TipSHA); err != nil { + return fmt.Errorf("restoring branch %s: %w", branch.Name, err) + } + } + if originalBranch != "" { + if err := ops.CheckoutBranch(originalBranch); err != nil { + return fmt.Errorf("restoring original checkout %s: %w", originalBranch, err) + } + } + original := originalTips(state) + for _, action := range state.Plan { + if action.NewName != "" && original[action.NewName] == "" && + (action.Type == "rename" || action.Type == "insert_below" || action.Type == "insert_above") && + existing[action.NewName] { + if err := ops.DeleteBranch(action.NewName, true); err != nil { + return fmt.Errorf("removing branch %s created by modify: %w", action.NewName, err) + } + } + } + return nil +} diff --git a/internal/modify/state.go b/internal/modify/state.go index 2bf2dc3..28a5875 100644 --- a/internal/modify/state.go +++ b/internal/modify/state.go @@ -6,9 +6,11 @@ import ( "fmt" "os" "path/filepath" + "slices" "time" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/worktree" ) const stateFileName = "gh-stack-modify-state" @@ -20,16 +22,22 @@ const ( ) // StateFile holds the state of an in-progress or pending-submit modify operation. -// It is stored at .git/gh-stack-modify-state. +// It is stored at /gh-stack-modify-state. type StateFile struct { - SchemaVersion int `json:"schema_version"` - StackName string `json:"stack_name"` - StackIndex int `json:"stack_index"` // index in StackFile.Stacks at modify start - StartedAt time.Time `json:"started_at"` - Phase string `json:"phase"` // "applying", "conflict", or "pending_submit" - PriorRemoteStackID string `json:"prior_remote_stack_id,omitempty"` - Snapshot Snapshot `json:"snapshot"` - Plan []Action `json:"plan"` + SchemaVersion int `json:"schema_version"` + StackName string `json:"stack_name"` + StackIndex int `json:"stack_index"` // legacy hint, never an identity + StartedAt time.Time `json:"started_at"` + Phase string `json:"phase"` + PriorRemoteStackID string `json:"prior_remote_stack_id,omitempty"` + Snapshot Snapshot `json:"snapshot"` + Plan []Action `json:"plan"` + Worktrees *worktree.Context `json:"worktrees,omitempty"` + StackBranches []string `json:"stack_branches"` + PreviousStackBranches []string `json:"previous_stack_branches"` + RenamedBranches map[string]string `json:"renamed_branches,omitempty"` + CreatedBranches map[string]string `json:"created_branches,omitempty"` + PendingAction *Action `json:"pending_action,omitempty"` // Conflict state — populated when phase is "conflict" ConflictBranch string `json:"conflict_branch,omitempty"` @@ -89,6 +97,9 @@ func LoadState(gitDir string) (*StateFile, error) { if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("parsing modify state: %w", err) } + if state.SchemaVersion > 1 { + return nil, fmt.Errorf("modify state uses unsupported schema version %d; upgrade gh-stack before recovery", state.SchemaVersion) + } return &state, nil } @@ -104,9 +115,81 @@ func SaveState(gitDir string, state *StateFile) error { return nil } +func (s *StateFile) RecordStack(target *stack.Stack) { + names := target.BranchNames() + if s.StackBranches != nil && !slices.Equal(s.StackBranches, names) { + s.PreviousStackBranches = s.StackBranches + } + s.StackName = target.Trunk.Branch + s.StackBranches = append([]string{}, names...) +} + +// MatchesStack never uses the catalog position as an identity. Older records +// can be identified by their remote ID or the original snapshot. +func MatchesStack(state *StateFile, target *stack.Stack) bool { + if state == nil || target == nil { + return false + } + if state.PriorRemoteStackID != "" && target.ID != "" { + return state.PriorRemoteStackID == target.ID + } + if state.StackBranches != nil { + if state.StackName != target.Trunk.Branch { + return false + } + if slices.Equal(state.StackBranches, target.BranchNames()) { + return true + } + // The journal is published before the catalog. An interrupted save + // may leave either definition on disk, but submit must match only + // the completed definition. + return state.Phase != PhasePendingSubmit && state.PreviousStackBranches != nil && + slices.Equal(state.PreviousStackBranches, target.BranchNames()) + } + var original stack.Stack + if err := json.Unmarshal(state.Snapshot.StackMetadata, &original); err != nil { + return false + } + if original.Trunk.Branch == "" || original.Trunk.Branch != target.Trunk.Branch { + return false + } + if slices.Equal(original.BranchNames(), target.BranchNames()) { + return true + } + names := original.BranchNames() + for _, action := range state.Plan { + switch action.Type { + case "rename": + for i, name := range names { + if name == action.Branch { + names[i] = action.NewName + } + } + case "drop", "fold_down", "fold_up": + names = slices.DeleteFunc(names, func(name string) bool { return name == action.Branch }) + case "insert_below", "insert_above": + if action.NewPosition < 0 || action.NewPosition > len(names) || action.NewName == "" { + return false + } + names = slices.Insert(names, action.NewPosition, action.NewName) + case "move": + index := slices.Index(names, action.Branch) + if index < 0 || action.NewPosition < 0 || action.NewPosition >= len(names) { + return false + } + names = slices.Delete(names, index, index+1) + names = slices.Insert(names, action.NewPosition, action.Branch) + } + } + return slices.Equal(names, target.BranchNames()) +} + // ClearState removes the modify state file. -func ClearState(gitDir string) { - _ = os.Remove(StatePath(gitDir)) +func ClearState(gitDir string) error { + if err := os.Remove(StatePath(gitDir)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing modify state: %w", err) + } + return nil } // StateExists returns true if a modify state file exists. @@ -121,7 +204,7 @@ func StateExists(gitDir string) bool { func CheckStateGuard(gitDir string) error { state, err := LoadState(gitDir) if err != nil { - return nil // ignore read errors + return err } if state == nil { return nil @@ -132,5 +215,8 @@ func CheckStateGuard(gitDir string) error { if state.Phase == PhaseConflict { return fmt.Errorf("a modify has unresolved conflicts — run `gh stack modify --continue` or `gh stack modify --abort`") } + if state.Phase != PhasePendingSubmit { + return fmt.Errorf("unrecognized modify state phase %q; recovery state was retained", state.Phase) + } return nil } diff --git a/internal/stack/lock.go b/internal/stack/lock.go index bf8e643..b4a2914 100644 --- a/internal/stack/lock.go +++ b/internal/stack/lock.go @@ -55,9 +55,9 @@ func Lock(gitDir string) (*FileLock, error) { return lock, err } -// LockOperation provides a separate operation lock in the given directory. -// Callers using it must acquire it before loading mutation state or taking the -// catalog lock. Save may be used while this lock is held. +// LockOperation serializes gh-stack mutations across the clone. Acquire it +// before loading mutation snapshots and before taking the catalog lock. +// Unlike the catalog lock, it may be held across Git operations. func LockOperation(commonDir string) (*FileLock, error) { lock, _, err := acquireLock(filepath.Join(commonDir, operationLockFileName), "stack operation", true) return lock, err diff --git a/internal/stack/migration.go b/internal/stack/migration.go index a8e5ff1..062a0e3 100644 --- a/internal/stack/migration.go +++ b/internal/stack/migration.go @@ -74,7 +74,6 @@ func HasLegacyState(commonDir string) (bool, error) { } // MigrateLegacyState consolidates legacy catalogs into the common directory. -// Command paths do not call this helper yet; catalog locations are unchanged. // The caller must hold LockOperation; this function takes the catalog lock. // Only disjoint stacks and equivalent duplicates are merged. Original bytes // are retained in *.pre-worktree-migration backups after common publication. diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 518a596..0e9db7e 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -225,7 +225,7 @@ func NearestSurvivingBranch(order []string, target string, survives func(string) return "" } -// StackFile represents the JSON file stored in .git/gh-stack. +// StackFile represents the JSON catalog stored in Git's common directory. type StackFile struct { SchemaVersion int `json:"schemaVersion"` Repository string `json:"repository"` @@ -310,7 +310,8 @@ func stackFilePath(gitDir string) string { return filepath.Join(gitDir, stackFileName) } -// Load reads the stack file from the given git directory. +// Load reads the catalog from the given directory, normally Git's common +// directory. Legacy recovery may explicitly use a worktree's original directory. // Returns an empty StackFile if the file does not exist. // The returned StackFile records a checksum of the on-disk content so that // Save can detect concurrent modifications. @@ -349,8 +350,8 @@ func parseStackFile(data []byte) (*StackFile, error) { // Save acquires an exclusive lock on the stack file, verifies the file hasn't // been modified since Load (optimistic concurrency), writes sf as JSON, and // releases the lock. The lock is held only for the read-compare-write window. -// Callers may hold the separate operation lock across Load/preflight/Save; -// Save itself only acquires the catalog lock. +// Mutation callers must separately hold the operation lock across their +// Load/preflight/Save sequence; read-only refreshes should use SaveNonBlocking. // Returns *LockError if the lock times out, or *StaleError if another process // modified the file since it was loaded. func Save(gitDir string, sf *StackFile) error { diff --git a/internal/worktree/context.go b/internal/worktree/context.go new file mode 100644 index 0000000..0db11ca --- /dev/null +++ b/internal/worktree/context.go @@ -0,0 +1,374 @@ +package worktree + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/github/gh-stack/internal/git" +) + +// Location records a worktree's stable administration-directory identity. +// Path is refreshed from Git if the worktree has moved. +type Location struct { + Path string `json:"path"` + ID string `json:"id,omitempty"` +} + +// Context binds a multi-branch operation to its original worktrees. Touched +// records the last ref value written by the operation, not merely a snapshot: +// recovery must not overwrite commits made after the operation stopped. +type Context struct { + Origin Location `json:"origin"` + Owners map[string]*Location `json:"owners,omitempty"` + Touched map[string]string `json:"touched,omitempty"` + Pending string `json:"pending,omitempty"` + PendingBefore string `json:"pendingBefore,omitempty"` +} + +func New() (*Context, error) { + root, err := git.RootDir() + if err != nil { + return nil, fmt.Errorf("finding initiating worktree: %w", err) + } + ctx := &Context{Origin: Location{Path: root}, Owners: make(map[string]*Location), Touched: make(map[string]string)} + if _, err := ctx.resolve(&ctx.Origin); err != nil { + return nil, err + } + trees, err := git.Worktrees() + if err != nil { + return nil, fmt.Errorf("listing worktrees: %w", err) + } + for _, tree := range trees { + if tree.Bare || tree.Branch == "" { + continue + } + if previous, ok := ctx.Owners[tree.Branch]; ok && !SamePath(previous.Path, tree.Path) { + return nil, fmt.Errorf("branch %s is checked out in multiple worktrees (%s and %s)", tree.Branch, previous.Path, tree.Path) + } + ctx.Owners[tree.Branch] = &Location{Path: tree.Path} + } + return ctx, nil +} + +func SamePath(a, b string) bool { + if filepath.Clean(a) == filepath.Clean(b) { + return true + } + ai, aerr := os.Stat(a) + bi, berr := os.Stat(b) + return aerr == nil && berr == nil && os.SameFile(ai, bi) +} + +func locationID(ops git.Ops, common string) (string, error) { + actualCommon, err := ops.CommonDir() + if err != nil { + return "", err + } + if !SamePath(actualCommon, common) { + return "", fmt.Errorf("worktree belongs to a different repository") + } + dir, err := ops.GitDir() + if err != nil { + return "", err + } + if SamePath(common, dir) { + return ".", nil + } + id, err := filepath.Rel(common, dir) + if err != nil { + return "", err + } + if id != "." && !filepath.IsLocal(id) { + return "", fmt.Errorf("worktree Git directory %s is outside %s", dir, common) + } + return id, nil +} + +func (c *Context) resolve(location *Location) (git.Ops, error) { + if location == nil || !filepath.IsAbs(location.Path) { + return nil, fmt.Errorf("recovery record has no absolute worktree path") + } + if location.ID != "" && location.ID != "." && !filepath.IsLocal(location.ID) { + return nil, fmt.Errorf("invalid worktree identity %q", location.ID) + } + common, err := git.CommonDir() + if err != nil { + return nil, err + } + ops, resolveErr := git.ForWorktree(location.Path) + var id string + if resolveErr == nil { + id, resolveErr = locationID(ops, common) + } + if resolveErr == nil && (location.ID == "" || location.ID == id) { + location.ID = id + return ops, nil + } + if resolveErr == nil { + resolveErr = fmt.Errorf("worktree identity changed") + } + if location.ID != "" { + trees, err := git.Worktrees() + if err != nil { + return nil, fmt.Errorf("locating moved worktree %s: %w", location.Path, err) + } + for _, tree := range trees { + if tree.Bare { + continue + } + candidate, err := git.ForWorktree(tree.Path) + if err != nil { + resolveErr = errors.Join(resolveErr, fmt.Errorf("opening candidate worktree %s: %w", tree.Path, err)) + continue + } + candidateID, err := locationID(candidate, common) + if err == nil && candidateID == location.ID { + location.Path = tree.Path + return candidate, nil + } + if err != nil { + resolveErr = errors.Join(resolveErr, fmt.Errorf("identifying candidate worktree %s: %w", tree.Path, err)) + } + } + } + return nil, fmt.Errorf("cannot use worktree %s: %w; restore or repair the worktree before continuing", location.Path, resolveErr) +} + +func (c *Context) Location(branch string) *Location { + if owner := c.Owners[branch]; owner != nil { + return owner + } + return &c.Origin +} + +func (c *Context) Ops(branch string) (git.Ops, error) { + return c.resolve(c.Location(branch)) +} + +func (c *Context) OriginOps() (git.Ops, error) { + return c.resolve(&c.Origin) +} + +// Busy includes clean but unfinished merge/sequencer operations, which a +// porcelain cleanliness check alone would miss. +func Busy(ops git.Ops) (bool, error) { + dir, err := ops.GitDir() + if err != nil { + return false, err + } + rebasing, err := ops.IsRebaseInProgress() + if err != nil { + return false, fmt.Errorf("checking rebase state: %w", err) + } + picking, err := ops.IsCherryPickInProgress() + if err != nil { + return false, fmt.Errorf("checking cherry-pick state: %w", err) + } + if rebasing || picking { + return true, nil + } + for _, marker := range []string{"MERGE_HEAD", "REVERT_HEAD", "sequencer", "rebase-merge", "rebase-apply"} { + if _, err := os.Stat(filepath.Join(dir, marker)); err == nil { + return true, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + } + return false, nil +} + +func CheckClean(ops git.Ops, path string) error { + busy, err := Busy(ops) + if err != nil { + return fmt.Errorf("checking worktree %s: %w", path, err) + } + if busy { + return fmt.Errorf("a Git operation is already in progress in worktree %s; complete or abort it first", path) + } + dirty, err := ops.HasUncommittedChanges() + if err != nil { + return fmt.Errorf("checking worktree %s: %w", path, err) + } + if dirty { + return fmt.Errorf("uncommitted changes in worktree %s; commit or stash them before continuing", path) + } + return nil +} + +func (c *Context) Preflight(branches []string) error { + checked := make(map[string]bool) + for _, branch := range branches { + ops, err := c.Ops(branch) + if err != nil { + return err + } + location := c.Location(branch) + if !checked[location.Path] { + if err := CheckClean(ops, location.Path); err != nil { + return err + } + checked[location.Path] = true + } + if !SamePath(location.Path, c.Origin.Path) { + current, err := ops.CurrentBranch() + if err != nil { + return fmt.Errorf("checking current branch in worktree %s: %w", location.Path, err) + } + if current != branch { + return fmt.Errorf("worktree %s no longer has branch %s checked out", location.Path, branch) + } + } + } + return nil +} + +// Prepare may switch the initiating worktree, but never a different worktree. +func (c *Context) Prepare(branch string) (git.Ops, error) { + if err := c.Preflight([]string{branch}); err != nil { + return nil, err + } + ops, err := c.Ops(branch) + if err != nil { + return nil, err + } + current, err := ops.CurrentBranch() + if err != nil { + return nil, err + } + if current != branch { + if err := ops.CheckoutBranch(branch); err != nil { + return nil, err + } + } + return ops, nil +} + +func (c *Context) Start(branch string, expected ...string) error { + ops, err := c.Ops(branch) + if err != nil { + return err + } + sha, err := ops.RevParse(branch) + if err != nil { + return err + } + if len(expected) > 0 && sha != expected[0] { + return fmt.Errorf("%s changed since this operation's snapshot; leaving it untouched", branch) + } + c.Pending, c.PendingBefore = branch, sha + return nil +} + +func (c *Context) Record(branch string) error { + ops, err := c.Ops(branch) + if err != nil { + return err + } + sha, err := ops.RevParse(branch) + if err != nil { + return err + } + if c.Touched == nil { + c.Touched = make(map[string]string) + } + c.Touched[branch] = sha + c.Pending, c.PendingBefore = "", "" + return nil +} + +func (c *Context) Rename(oldName, newName string) { + if owner := c.Owners[oldName]; owner != nil { + c.Owners[newName] = owner + delete(c.Owners, oldName) + } + if sha, ok := c.Touched[oldName]; ok { + c.Touched[newName] = sha + delete(c.Touched, oldName) + } + if c.Pending == oldName { + c.Pending = newName + } +} + +func (c *Context) Restore(originalRefs map[string]string) error { + var failures []string + if c.Pending != "" { + ops, err := c.Ops(c.Pending) + if err != nil { + failures = append(failures, err.Error()) + } else if sha, err := ops.RevParse(c.Pending); err != nil || sha != c.PendingBefore { + failures = append(failures, fmt.Sprintf("cannot prove the last update of %s completed safely; restore it manually before retrying", c.Pending)) + } else { + c.Pending, c.PendingBefore = "", "" + } + } + names := make([]string, 0, len(c.Touched)) + for name := range c.Touched { + names = append(names, name) + } + sort.Strings(names) + for _, branch := range names { + original, ok := originalRefs[branch] + if !ok { + failures = append(failures, fmt.Sprintf("no original ref recorded for %s", branch)) + continue + } + ops, err := c.Ops(branch) + if err != nil { + failures = append(failures, err.Error()) + continue + } + current, err := ops.RevParse(branch) + if err != nil { + failures = append(failures, fmt.Sprintf("reading %s: %v", branch, err)) + continue + } + if current == original { + delete(c.Touched, branch) + continue + } + if current != c.Touched[branch] { + failures = append(failures, fmt.Sprintf("%s changed after this operation; leaving it untouched", branch)) + continue + } + checkedOut, branchErr := ops.CurrentBranch() + if branchErr == nil && checkedOut == branch { + if err = CheckClean(ops, c.Location(branch).Path); err == nil { + err = ops.ResetHard(original) + } + } else { + err = ops.UpdateBranchRef(branch, original) + } + if err != nil { + failures = append(failures, fmt.Sprintf("restoring %s in %s: %v", branch, c.Location(branch).Path, err)) + continue + } + delete(c.Touched, branch) + } + if len(failures) > 0 { + return errors.New(strings.Join(failures, "\n")) + } + return nil +} + +func (c *Context) RestoreOrigin(branch string) error { + ops, err := c.OriginOps() + if err != nil { + return err + } + current, err := ops.CurrentBranch() + if err == nil && current == branch { + return nil + } + if err := CheckClean(ops, c.Origin.Path); err != nil { + return err + } + if err := ops.CheckoutBranch(branch); err != nil { + return fmt.Errorf("restoring checkout %s in %s: %w", branch, c.Origin.Path, err) + } + return nil +} diff --git a/internal/worktree/context_test.go b/internal/worktree/context_test.go new file mode 100644 index 0000000..292e40b --- /dev/null +++ b/internal/worktree/context_test.go @@ -0,0 +1,250 @@ +package worktree + +import ( + "encoding/json" + "errors" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRestorePreservesLaterCommits(t *testing.T) { + common := t.TempDir() + root := filepath.Join(common, "root") + resets := 0 + mock := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return common, nil }, + CurrentBranchFn: func() (string, error) { return "branch", nil }, + RevParseFn: func(string) (string, error) { return "user-commit", nil }, + HasUncommittedChangesFn: func() (bool, error) { return false, nil }, + ResetHardFn: func(string) error { resets++; return nil }, + UpdateBranchRefFn: func(string, string) error { resets++; return nil }, + } + restore := git.SetOps(mock) + defer restore() + ctx := &Context{Origin: Location{Path: root, ID: "."}, Touched: map[string]string{"branch": "our-rebase"}} + + err := ctx.Restore(map[string]string{"branch": "original"}) + + require.ErrorContains(t, err, "changed after this operation") + assert.Zero(t, resets) + assert.Equal(t, "our-rebase", ctx.Touched["branch"]) +} + +func TestRestoreOnlyResetsTouchedBranches(t *testing.T) { + common := t.TempDir() + var resets []string + mock := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return common, nil }, + CurrentBranchFn: func() (string, error) { return "ours", nil }, + RevParseFn: func(branch string) (string, error) { + require.Equal(t, "ours", branch) + return "rebased", nil + }, + ResetHardFn: func(ref string) error { resets = append(resets, ref); return nil }, + } + restore := git.SetOps(mock) + defer restore() + ctx := &Context{Origin: Location{Path: common, ID: "."}, Touched: map[string]string{"ours": "rebased"}} + + require.NoError(t, ctx.Restore(map[string]string{"ours": "before", "unrelated": "old-unrelated"})) + + assert.Equal(t, []string{"before"}, resets) + assert.Empty(t, ctx.Touched) +} + +func TestDirtyRestoreRetainsRoundTrippableProgress(t *testing.T) { + common := t.TempDir() + mock := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return common, nil }, + CurrentBranchFn: func() (string, error) { return "branch", nil }, + RevParseFn: func(string) (string, error) { return "rebased", nil }, + HasUncommittedChangesFn: func() (bool, error) { return true, nil }, + ResetHardFn: func(string) error { t.Fatal("must not reset dirty files"); return nil }, + } + restore := git.SetOps(mock) + defer restore() + ctx := &Context{Origin: Location{Path: common, ID: "."}, Touched: map[string]string{"branch": "rebased"}} + + require.ErrorContains(t, ctx.Restore(map[string]string{"branch": "before"}), "uncommitted") + data, err := json.Marshal(ctx) + require.NoError(t, err) + var resumed Context + require.NoError(t, json.Unmarshal(data, &resumed)) + mock.HasUncommittedChangesFn = func() (bool, error) { return false, nil } + var reset string + mock.ResetHardFn = func(ref string) error { reset = ref; return nil } + require.NoError(t, resumed.Restore(map[string]string{"branch": "before"})) + assert.Equal(t, "before", reset) +} + +func TestMovedWorktreeUsesAdministrationIdentity(t *testing.T) { + for _, failure := range []string{"factory", "identity"} { + t.Run(failure, func(t *testing.T) { + common := t.TempDir() + oldPath, newPath := filepath.Join(common, "old"), filepath.Join(common, "new") + unavailable := filepath.Join(common, "unavailable") + owner := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return filepath.Join(common, "worktrees", "branch"), nil }, + } + root := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + WorktreesFn: func() ([]git.Worktree, error) { + return []git.Worktree{{Path: unavailable}, {Path: newPath, Branch: "branch"}}, nil + }, + ForWorktreeFn: func(path string) (git.Ops, error) { + if path == newPath { + return owner, nil + } + if failure == "factory" || path == unavailable { + return nil, errors.New("worktree moved") + } + return &git.MockOps{CommonDirFn: func() (string, error) { + return "", errors.New("worktree identity unavailable") + }}, nil + }, + } + restore := git.SetOps(root) + defer restore() + ctx := &Context{Owners: map[string]*Location{"branch": {Path: oldPath, ID: filepath.Join("worktrees", "branch")}}} + + ops, err := ctx.Ops("branch") + + require.NoError(t, err) + assert.Same(t, owner, ops) + assert.Equal(t, newPath, ctx.Location("branch").Path) + }) + } +} + +func TestFactoryFailureDoesNotUseInvokingWorktree(t *testing.T) { + common, origin := t.TempDir(), t.TempDir() + scopeErr := errors.New("recorded worktree unavailable") + restore := git.SetOps(&git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + ForWorktreeFn: func(string) (git.Ops, error) { + return nil, scopeErr + }, + CheckoutBranchFn: func(string) error { + t.Fatal("failed owner lookup must not fall back to the invoking worktree") + return nil + }, + }) + defer restore() + ctx := &Context{Origin: Location{Path: origin, ID: "."}} + + ops, err := ctx.Prepare("branch") + + require.ErrorIs(t, err, scopeErr) + assert.Nil(t, ops) + assert.Equal(t, origin, ctx.Origin.Path) +} + +func TestPreflightStateFailureDoesNotCheckout(t *testing.T) { + for _, query := range []string{"rebase", "cherry-pick"} { + t.Run(query, func(t *testing.T) { + common, origin := t.TempDir(), t.TempDir() + lookupErr := errors.New("state lookup failed") + mock := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return common, nil }, + CheckoutBranchFn: func(string) error { + t.Fatal("state lookup failure must precede checkout") + return nil + }, + } + fail := func() (bool, error) { return false, lookupErr } + if query == "rebase" { + mock.IsRebaseInProgressFn = fail + } else { + mock.IsCherryPickInProgressFn = fail + } + restore := git.SetOps(mock) + defer restore() + ctx := &Context{Origin: Location{Path: origin, ID: "."}} + + ops, err := ctx.Prepare("branch") + + require.ErrorIs(t, err, lookupErr) + assert.Nil(t, ops) + }) + } +} + +func TestPreflightDoesNotInspectUnrelatedWorktrees(t *testing.T) { + common := t.TempDir() + rootPath := filepath.Join(common, "root") + affectedPath := filepath.Join(common, "affected") + unrelatedPath := filepath.Join(common, "unrelated") + inspectedUnrelated := 0 + mock := &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + RootDirFn: func() (string, error) { return rootPath, nil }, + WorktreesFn: func() ([]git.Worktree, error) { + return []git.Worktree{{Path: affectedPath, Branch: "affected"}, {Path: unrelatedPath, Branch: "unrelated"}}, nil + }, + } + mock.ForWorktreeFn = func(path string) (git.Ops, error) { + branch := filepath.Base(path) + dir := common + if path != rootPath { + dir = filepath.Join(common, "worktrees", branch) + } + return &git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return dir, nil }, + CurrentBranchFn: func() (string, error) { return branch, nil }, + HasUncommittedChangesFn: func() (bool, error) { + if path == unrelatedPath { + inspectedUnrelated++ + return true, nil + } + return false, nil + }, + }, nil + } + restore := git.SetOps(mock) + defer restore() + ctx, err := New() + require.NoError(t, err) + + require.NoError(t, ctx.Preflight([]string{"affected"})) + assert.Zero(t, inspectedUnrelated) + require.ErrorContains(t, ctx.Preflight([]string{"unrelated"}), "uncommitted") +} + +func TestInvalidRecoveryLocationDoesNotExecuteGit(t *testing.T) { + mock := &git.MockOps{ForWorktreeFn: func(string) (git.Ops, error) { + t.Fatal("must not use the calling worktree for an invalid record") + return nil, nil + }} + restore := git.SetOps(mock) + defer restore() + for _, location := range []Location{{}, {Path: "relative"}, {Path: t.TempDir(), ID: "../other-repo"}} { + ctx := &Context{Origin: location} + _, err := ctx.OriginOps() + require.Error(t, err) + } +} + +func TestStartRejectsCommitsAfterSnapshot(t *testing.T) { + common := t.TempDir() + restore := git.SetOps(&git.MockOps{ + CommonDirFn: func() (string, error) { return common, nil }, + GitDirFn: func() (string, error) { return common, nil }, + RevParseFn: func(string) (string, error) { return "new-user-commit", nil }, + }) + defer restore() + ctx := &Context{Origin: Location{Path: common, ID: "."}} + + require.ErrorContains(t, ctx.Start("branch", "snapshot-commit"), "changed since") + assert.Empty(t, ctx.Pending) + assert.Empty(t, ctx.Touched) +} diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index bb75496..f948c90 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -29,6 +29,8 @@ layers, read `references/stack-design.md`. ## Setup +Requires Git 2.36+ and an authenticated GitHub CLI. + ```bash gh extension install github/gh-stack git config rerere.enabled true # remember conflict resolutions @@ -59,6 +61,10 @@ Agent harnesses differ, so always pass the flags below instead of relying on tha - `view --short` is safe in both modes, but it is formatted for humans. Use `--json` to parse. - **`checkout ` when a different local stack already covers those branches** cannot be forced. Run `gh stack unstack --local` first (this keeps the stack on GitHub), then retry. +- **Worktrees:** local stacks share one common-directory catalog. Use `--print-path` with + navigation or explicit-target `checkout` to locate a foreign-owned branch without stealing its + checkout. Unoccupied targets are checked out here first. Check the exit status before changing + directories; parse only successful path-mode stdout, never status messages. ## Branch placement @@ -162,6 +168,14 @@ an ancestor of the branch. ## Constraints - Stacks are strictly linear: one parent, at most one child. Use separate stacks for parallel work. +- `rebase` and `sync` currently require all members and writable trunks to be unoccupied or owned + by the invoking worktree. They refuse distributed rewrites before requested changes, after + prerequisite catalog migration. They never auto-stash or create/remove worktrees. Mutations + serialize across the clone; rebase recovery must run in its recorded origin. Legacy recovery + must finish in its original worktree before migration. +- Core `modify` temporarily rejects distributed stack branches before TUI/apply. Linked-worktree + use is allowed when all member branches are unoccupied or owned here; trunk ownership alone is + not a blocker. Its recovery flags still use the recorded origin from any linked worktree. - There is no non-interactive reorder or removal. Errors may suggest `gh stack modify`, but it is TUI-only — restructure with `unstack` then `init` instead. - PR titles and bodies are auto-generated. Use `gh pr edit` afterwards to change them. diff --git a/skills/gh-stack/references/commands.md b/skills/gh-stack/references/commands.md index fcb219a..3825396 100644 --- a/skills/gh-stack/references/commands.md +++ b/skills/gh-stack/references/commands.md @@ -23,6 +23,9 @@ explain: preconditions, side effects, atomicity, and failure modes. Creates the stack and checks out the **last** branch in the list, so a single `init` can lay down the whole chain: `gh stack init auth api frontend`. +If the final existing branch is already checked out in another worktree, adoption still succeeds: +the command reports its owner and leaves the invoking checkout unchanged. + `init` processes branch arguments from bottom to top. Existing branches are adopted. If the first branch does not exist, it is created from the trunk; each later new branch is created from the branch immediately before it. There is no separate adopt mode — existence decides. `--base` @@ -42,6 +45,9 @@ selects a non-default trunk. immediately after `init` — instead of creating a branch. This is deliberate: the first layer usually needs its content before a second layer exists. - `-A` and `-u` are mutually exclusive, and both require `-m`. +- Existing branches owned by another worktree may be adopted without checkout. Commit/stage + shortcuts are rejected before staging or membership changes; they never commit another + worktree's files. ## push @@ -74,7 +80,7 @@ first non-merged ancestor, then links them into a Stack on GitHub. ## link Creates or updates a stack on GitHub **without any local tracking state**. This is the path for -branches managed by another tool or living in another worktree — see `troubleshooting.md`. +branches managed by another tool. Worktrees alone do not require `link`: local tracking is shared. - Arguments are given bottom to top. Each is a branch name or a PR number; a numeric argument is tried as a PR number first and falls back to a branch name. @@ -105,6 +111,11 @@ The routine command. Steps, in order: 8. **Prune** local branches for merged PRs, only when `--prune` is passed in a non-interactive environment. +Foreign-owned members, including merged branches, and foreign-owned trunks are currently refused +before requested mutations. Remote additions/replacements are checked before import. Prerequisite +catalog migration may already have completed. Cascade rollback does not undo prior fetches or +completed fast-forwards; partial restoration failures retain recovery state. + ## rebase Pulls from the remote and cascade-rebases. Use it when `sync` reported a conflict or when you need @@ -119,6 +130,10 @@ to rebase only part of the stack. - A merged PR is detected automatically and replayed with `--onto` against the correct target, so a squash-merged parent does not produce spurious conflicts. - Starting a rebase while one is in progress exits **7**. +- All members must currently be unoccupied or checked out in the initiating worktree, even those + outside the selected range. Foreign-owned trunks require `--no-trunk`. Resolve/stage conflicts + at the reported path and invoke `--continue`/`--abort` there; recovery from another worktree is + refused. No auto-stash or worktree lifecycle management. ## view @@ -138,7 +153,10 @@ Accepts a stack number, PR number, PR URL, or branch name. stack up locally. - If a local stack already exists over those branches with a different composition, `checkout` cannot be forced past it. Run `gh stack unstack --local` first, then retry. -- `checkout` has no flags. It relies on `remote.pushDefault` when several remotes exist. +- `checkout` relies on `remote.pushDefault` when several remotes exist. +- `--print-path` requires an explicit target and never prompts. It prints a foreign owner's path + without switching, or checks out an unoccupied target here before printing the current root. + Without path mode, a foreign-owned target is a nonzero error, not a successful switch. ## unstack @@ -175,3 +193,22 @@ count (`gh stack up 3`). Movement clamps at the stack bounds, and merged branche navigating from an active branch, so `bottom` lands on the lowest *unmerged* branch. `gh stack switch` is a selection menu with no non-interactive path. Use the commands above instead. + +All five navigation commands support `--print-path`, as does explicit-target `checkout`. Success +writes only an absolute raw path and newline to stdout; diagnostics go to stderr and errors leave +stdout empty. Check the exit status before using the path: + +```bash +gscd() { + local target + target=$(gh stack "$@" --print-path) || return $? + if [ -z "$target" ]; then + printf '%s\n' 'gh stack returned an empty path' >&2 + return 1 + fi + cd -- "$target" +} +gscd checkout auth +``` + +This is a Bash/Zsh function, not something gh-stack installs. Never use `eval` on path output. diff --git a/skills/gh-stack/references/troubleshooting.md b/skills/gh-stack/references/troubleshooting.md index fc97b41..59b2cfc 100644 --- a/skills/gh-stack/references/troubleshooting.md +++ b/skills/gh-stack/references/troubleshooting.md @@ -127,8 +127,9 @@ problem entirely, since they do not infer the stack from the current branch. ## Driving stacks from another tool or worktree `gh stack link` creates and updates stacks purely through the API, with no local tracking state. -Use it when branches are managed by jj, Sapling, git-town, a separate worktree, or any workflow -where the local `.git/gh-stack` file would be wrong or absent. +Use it when branches are managed by jj, Sapling, git-town, or another external workflow that does +not use gh-stack's local catalog. Linked worktrees themselves are supported: they share +`/gh-stack` and do not require `link`. ```bash gh stack link branch-a branch-b branch-c # bottom to top @@ -140,11 +141,30 @@ gh stack link 7 feature-d # append to existing stack #7 Because `link` writes no local state, the local navigation commands (`up`, `down`, `top`, `bottom`) will not work on the result. Use `gh stack checkout ` if you later want local tracking. +Git 2.36+ is required. Legacy worktree catalogs are consolidated automatically only when their +definitions agree or are disjoint; originals are preserved. On migration conflicts, reconcile the +reported source definitions rather than choosing the newest file. Finish legacy operations in +their original worktree first, and do not mix old and new gh-stack writers in one clone. + +Navigation does not take over another worktree's checkout. Use `--print-path` with an explicit +target, check the exit status, and change directory to the quoted output. Rebase/sync currently +refuse foreign-owned members or writable trunks rather than rewriting across worktrees. Keep +their target branches unoccupied or owned by the initiating worktree; rebase recovery must also +be invoked in that origin. Prerequisite catalog migration may finish before a rewrite refusal. +gh-stack does not automatically stash or create/remove worktrees. + +For `git init --separate-git-dir` repositories, Git may list the administration directory as the +main path instead of the actual checkout. Operations from a known main or linked origin remain +supported, but main-owner discovery from another checkout can be unavailable. Do not navigate to +an administration directory or guess its associated checkout; run from the actual main worktree +when its working files are needed. No private registry or Git config changes are used to infer it. + ## Stack file is locked (exit 8) -Another `gh stack` process holds the exclusive lock on `.git/gh-stack.lock`. The lock times out -after about five seconds, so wait and retry. A persistent exit 8 means another process still holds -the lock; identify and stop that process before retrying. +Another `gh stack` process holds either the short catalog lock (`/gh-stack.lock`) or +the clone-wide mutation lock (`/gh-stack-operation.lock`). Wait and retry; read-only +views remain available. Do not delete lock files to bypass coordination. Paused operations are +also guarded by shared recovery journals after their process lock has been released. ## An interrupted modify session (exit 10) @@ -156,4 +176,10 @@ gh stack modify --abort ``` Related: `submit` also detects a pending modify state, and under a TTY asks before overwriting the -stack on GitHub with local state. +matching stack on GitHub with local state. An unrelated stack cannot consume or clear that journal. + +Core modify temporarily rejects stack branches checked out in other worktrees. It works in a +linked worktree when every member branch is unoccupied or owned there; a foreign trunk is allowed. +Shared-journal continue/abort executes in the recorded origin even when invoked elsewhere. Native +Git markers remain per-worktree. If recovery reports missing owners, externally changed refs, or +save failures, fix the reported problem and retry; the journal is retained to prevent false success.