diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 36a0e1cb..a383a364 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,9 @@ 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. Pass the snapshot SHA (or prior `Context.Touched` SHA) to `Context.Start` before ref mutations; never claim an external commit as this operation's work during continuation. +- 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 efe966fb..0623ead6 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,17 +111,22 @@ 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. - **`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 origin/owner identities, original refs, and progress. Native Git markers remain per-worktree. Continue/abort must use recorded scoped executors, 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. Call `Context.Start(branch, expectedSHA)` before ref mutations: use the original snapshot SHA, or the last `Context.Touched` SHA for a branch already changed by this operation. Do not adopt a freshly read tip as this 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. ## CI workflows (`.github/workflows/`) @@ -134,4 +142,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 e07a4aa0..26e2090e 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. 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 distribute a stack's branches across them. `rebase` and `sync` automatically update the clean worktree that owns each affected branch. Dirty, busy, missing, or changed owners stop the operation; unrelated worktrees are left alone. A trunk that cannot safely fast-forward can use the existing fetched-remote fallback. + +Mutations are serialized across the clone, while read-only views remain available. Paused operations must be continued or aborted before another mutation. Recovery runs in the recorded worktree even when `--continue` or `--abort` is invoked elsewhere. 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 5b994db3..4c630cf5 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) @@ -168,6 +175,16 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { // If the branch already exists in git but is not part of any stack, // adopt it instead of erroring. This mirrors the init command's behavior. adopted := git.BranchExists(branchName) + 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) @@ -193,9 +210,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 @@ -219,7 +242,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 @@ -237,10 +260,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 @@ -286,13 +326,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 b11b5be0..eb58dfee 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 { return true }, + 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 { return true }, + 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 c15f2d6c..ba926dc3 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 f82c1f82..04d440b9 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 := git.ForWorktree(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 533f47f0..54e06ccc 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 @@ -152,7 +167,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 } @@ -205,23 +220,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 } @@ -254,6 +278,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)) @@ -281,6 +310,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) @@ -345,6 +378,11 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, trunkRef cfg.Errorf("branch %q already exists in a stack", branchName) return nil, false, ErrInvalidArgs } + if beforeCreate != nil { + if err := beforeCreate(branchName); err != nil { + return nil, false, err + } + } if git.BranchExists(branchName) { wasAdopted = true } else { @@ -378,6 +416,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 @@ -396,7 +438,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 1b110415..d7a0e04d 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" @@ -14,6 +15,55 @@ import ( "github.com/stretchr/testify/require" ) +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 { return true }, + 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 { return true }, + 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 033478dc..8ede93e4 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 96fd1a6d..52b5d543 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 { return branchSet[name] }, PushFn: func(string, []string, bool, bool) error { return nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, @@ -155,6 +156,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) { @@ -188,6 +190,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) { @@ -220,6 +223,7 @@ func TestLink_PRNumbers_MultipleStacks(t *testing.T) { } func TestLink_TooFewArgs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} @@ -234,6 +238,7 @@ func TestLink_TooFewArgs(t *testing.T) { } func TestLink_DuplicateArgs(t *testing.T) { + defer mockRemoteOnlyGit()() cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{} @@ -252,6 +257,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{ @@ -314,6 +320,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) { @@ -344,6 +351,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) { @@ -373,6 +381,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) { @@ -413,6 +422,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) { @@ -530,6 +540,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) { @@ -727,6 +738,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) { @@ -1322,6 +1334,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 { return false }, DefaultBranchFn: func() (string, error) { defaultBranchCalled = true @@ -1387,6 +1400,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 { return name == "feat-a" || name == "feat-b" }, PushFn: func(string, []string, bool, bool) error { return nil }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, @@ -1432,6 +1446,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 { return false }, DefaultBranchFn: func() (string, error) { return "", fmt.Errorf("no default branch") }, }) @@ -1466,6 +1481,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 { return false }, DefaultBranchFn: func() (string, error) { defaultBranchCalled = true @@ -1525,6 +1541,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) { @@ -1593,6 +1610,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 { return name == "feat-a" || name == "feat-b" }, ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, PushFn: func(remote string, branches []string, force, atomic bool) error { @@ -1640,6 +1658,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 { return true }, PushFn: func(remote string, branches []string, force, atomic bool) error { pushedRemote = remote @@ -1679,6 +1698,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 { return false }, // PR numbers aren't local branches PushFn: func(string, []string, bool, bool) error { pushCalled = true @@ -1865,6 +1885,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() @@ -2042,6 +2063,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") }, @@ -2136,6 +2158,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) { @@ -2222,6 +2245,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() @@ -2312,6 +2336,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) { @@ -2348,6 +2373,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{ @@ -2390,6 +2416,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) { @@ -2426,6 +2453,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) { @@ -2466,6 +2494,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{ @@ -2555,6 +2584,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{ @@ -2641,6 +2671,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) { @@ -2676,6 +2707,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) { @@ -2710,6 +2742,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 be61147b..e8e6741b 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 e9e63609..11a7c7b4 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 341ae28d..67929539 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 @@ -309,6 +341,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). @@ -351,8 +387,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 b6fa9cbb..9ceb2e29 100644 --- a/cmd/modify_test.go +++ b/cmd/modify_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -15,6 +16,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" ) @@ -420,9 +422,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) { @@ -467,11 +467,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) { @@ -752,11 +752,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) { @@ -769,7 +767,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") } // --------------------------------------------------------------------------- @@ -818,11 +816,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 { return true }, + IsRebaseInProgressFn: func() bool { return inProgress }, IsCherryPickInProgressFn: func() bool { return false }, - RebaseAbortFn: func() error { rebaseAborted = true; return nil }, + RebaseAbortFn: func() error { rebaseAborted = true; inProgress = false; return nil }, BranchExistsFn: func(string) bool { return true }, CheckoutBranchFn: func(name string) error { current = name; return nil }, ResetHardFn: func(sha string) error { @@ -900,3 +899,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 { return true }, + 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 { return inProgress && tc.conflictType == "rebase" }, + 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 { return inProgress && tc.conflictType == "cherry_pick" }, + 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 { callerSensitiveCalls++; return false }, + IsCherryPickInProgressFn: func() bool { + callerSensitiveCalls++ + return false + }, + 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 { + require.True(t, worktree.SamePath(path, origin)) + return originOps + } + 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 { return true }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + IsAncestorFn: func(string, string) (bool, error) { return false, nil }, + IsRebaseInProgressFn: func() bool { return inProgress }, + 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 fe0fe535..182fce46 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 90feefb5..8e4a3634 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 { return true }, + 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 34bee03d..9455a309 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 3b4d4827..faf91bc8 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -12,6 +12,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" ) @@ -27,6 +28,15 @@ type rebaseOptions struct { } type rebaseState struct { + 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"` + RebaseBase string `json:"rebaseBase,omitempty"` + RebaseOldBase string `json:"rebaseOldBase,omitempty"` + RebaseOnto bool `json:"rebaseOnto,omitempty"` CurrentBranchIndex int `json:"currentBranchIndex"` ConflictBranch string `json:"conflictBranch"` RemainingBranches []string `json:"remainingBranches"` @@ -97,7 +107,18 @@ 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 { + return err + } + defer release() + gitDir, err := stackStateDir(cfg) if err != nil { cfg.Errorf("not a git repository") return ErrNotInStack @@ -118,11 +139,47 @@ 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 + originalTrunk := s.Trunk.Branch + + anchor := currentBranch + if opts.branch != "" { + anchor = opts.branch + } + currentIdx := s.IndexOf(anchor) + if currentIdx < 0 { + currentIdx = 0 + } + startIdx, endIdx := 0, len(s.Branches) + if endIdx == 0 { + cfg.Printf("No branches to rebase") + return nil + } + if opts.downstack { + endIdx = currentIdx + 1 + } + if opts.upstack { + startIdx = currentIdx + } + if opts.noTrunk && startIdx < 1 { + startIdx = 1 + } + branchesToRebase := s.Branches[startIdx:endIdx] + if len(branchesToRebase) == 0 { + cfg.Printf("No branches to rebase") + return nil + } + _ = syncStackPRs(cfg, s) + ctx, err := worktree.New() + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + required := rebaseBranchNames(branchesToRebase) // Enable git rerere so conflict resolutions are remembered. if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { @@ -132,7 +189,7 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { var trunk trunkTarget 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) @@ -140,58 +197,41 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrSilent } - trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch) - if err != nil { - return err - } - // Fast-forward stack branches that are behind their remote tracking branch. if err := git.FetchBranches(remote, activeBranchNames(s)); err != nil { cfg.Errorf("failed to fetch stack branches from %s: %v", remote, err) return ErrSilent } - fastForwardBranches(cfg, s, remote, currentBranch) + planned := planFastForwardBranches(s, remote) + for _, forward := range planned { + required = append(required, forward.Branch) + } + if err := ctx.Preflight(required); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + trunk, err = resolveTrunkTarget(cfg, s, remote, currentBranch, trunkResolveOptions{Worktrees: ctx}) + if err != nil { + return err + } + if _, err := fastForwardBranches(cfg, planned, ctx); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + } else if err := ctx.Preflight(required); err != nil { + cfg.Errorf("%s", err) + return ErrSilent } cfg.Printf("Stack detected: %s", s.DisplayChain()) - currentIdx := s.IndexOf(currentBranch) - if currentIdx < 0 { - currentIdx = 0 - } - if opts.upstack && currentIdx >= 0 && s.Branches[currentIdx].IsMerged() { cfg.Warningf("Current branch %q has already been merged", currentBranch) } - startIdx := 0 - endIdx := len(s.Branches) - - if opts.downstack { - endIdx = currentIdx + 1 - } - if opts.upstack { - startIdx = currentIdx - } - - // With --no-trunk, skip the first branch (which would rebase onto trunk). - if opts.noTrunk && startIdx < 1 { - startIdx = 1 - } - - branchesToRebase := s.Branches[startIdx:endIdx] - - if len(branchesToRebase) == 0 { - cfg.Printf("No branches to rebase") - return nil - } - cfg.Printf("Rebasing branches in order, starting from %s to %s", branchesToRebase[0].Branch, branchesToRebase[len(branchesToRebase)-1].Branch) - // Sync PR state before rebase so we can detect merged PRs. - _ = syncStackPRs(cfg, s) - originalRefs, err := resolveOriginalRefs(s) if err != nil { return fmt.Errorf("resolving branch refs: %w", err) @@ -212,6 +252,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, @@ -222,41 +275,22 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { OntoOldBase: ontoOldBase, CommitterDateIsAuthorDate: opts.committerDateIsAuthorDate, TrunkRef: trunk.Ref, + TrunkSHA: trunk.SHA, + Worktrees: ctx, + State: state, + StateDir: gitDir, }) if rebaseResult.Err != nil { cfg.Errorf("%v", rebaseResult.Err) - if rebaseResult.Rebased { - restoreRebaseRefs(cfg, currentBranch, originalRefs) - } else { - _ = git.CheckoutBranch(currentBranch) - } + _ = rollbackWorktreeRebase(cfg, gitDir, state) return ErrSilent } 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, - } - if err := saveRebaseState(gitDir, state); err != nil { - cfg.Warningf("failed to save rebase state: %s", err) - } - - printConflictDetails(cfg, rebaseResult.ConflictBase) + printWorktreeConflict(cfg, state, rebaseResult.ConflictBase) cfg.Printf("") cfg.Printf("Resolve conflicts on %s, then run `%s`", @@ -266,22 +300,10 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return ErrConflict } - _ = git.CheckoutBranch(currentBranch) - - if unstacked := verifyStacked(s, trunk.Ref, startIdx, endIdx); len(unstacked) > 0 { - reportUnstacked(cfg, trunk.Ref, unstacked) - if rebaseResult.Rebased { - restoreRebaseRefs(cfg, currentBranch, originalRefs) - } - return ErrSilent + if err := finishWorktreeRebase(cfg, gitDir, state, sf, s); err != nil { + return err } - updateBaseSHAs(s) - - _ = syncStackPRs(cfg, s) - - stack.SaveNonBlocking(gitDir, sf) - merged := s.MergedBranches() if len(merged) > 0 { names := make([]string, len(merged)) @@ -293,9 +315,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 { @@ -315,6 +337,9 @@ func continueRebase(cfg *config.Config, gitDir string) error { cfg.Errorf("no rebase in progress") return ErrSilent } + if state.Worktrees != nil { + return continueWorktreeRebase(cfg, gitDir, state) + } sf, err := stack.Load(gitDir) if err != nil { @@ -458,12 +483,17 @@ func continueRebase(cfg *config.Config, gitDir string) error { return ErrSilent } - clearRebaseState(gitDir) - updateBaseSHAs(s) + updateBaseSHAsWithTrunk(s, state.TrunkSHA) _ = syncStackPRs(cfg, s) - stack.SaveNonBlocking(gitDir, sf) + if err := stack.Save(gitDir, sf); err != nil { + return handleSaveError(cfg, err) + } + if err := clearRebaseState(gitDir); err != nil { + cfg.Errorf("rebase completed but recovery state could not be cleared: %v", err) + return ErrSilent + } if state.NoTrunk { cfg.Printf("All branches in stack rebased locally (without trunk)") @@ -484,6 +514,13 @@ func abortRebase(cfg *config.Config, gitDir string) error { cfg.Errorf("no rebase in progress") return ErrSilent } + if state.Worktrees != nil { + if err := rollbackWorktreeRebase(cfg, gitDir, state); err != nil { + return ErrSilent + } + cfg.Successf("Rebase aborted and branches restored") + return nil + } if git.IsRebaseInProgress() { _ = git.RebaseAbort() @@ -520,7 +557,7 @@ func saveRebaseState(gitDir string, state *rebaseState) error { if err != nil { return fmt.Errorf("error serializing rebase state: %w", err) } - if err := os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644); err != nil { + if err := stack.WriteAtomic(filepath.Join(gitDir, rebaseStateFile), data); err != nil { return fmt.Errorf("error writing rebase state: %w", err) } return nil @@ -538,8 +575,12 @@ func loadRebaseState(gitDir string) (*rebaseState, error) { 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) { @@ -547,12 +588,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 1be2f9f9..4d1861b7 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -15,6 +15,7 @@ import ( "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" ) @@ -103,7 +104,7 @@ func TestRebase_CascadeRebase(t *testing.T) { // All branches should be rebased in order: b1 onto main, b2 onto b1, b3 onto b2 require.Len(t, allRebaseCalls, 3) - assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "sha-main", allRebaseCalls[0].newBase, "b1 should be rebased onto the pinned trunk") assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") assert.Equal(t, "b2", allRebaseCalls[2].newBase, "b3 should be rebased onto b2") @@ -168,7 +169,7 @@ func TestRebase_MergedBranch_UsesOnto(t *testing.T) { // b2: onto trunk, oldBase = b1's original SHA // b3: onto b2, oldBase = b2's original SHA (propagation) require.Len(t, rebaseCalls, 2) - assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseCalls[0], + assert.Equal(t, rebaseCall{"default-sha", "b1-orig-sha", "b2"}, rebaseCalls[0], "b2 should rebase --onto main using b1's original SHA as oldBase") assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[1], "b3 should propagate --onto mode with b2's original SHA as oldBase") @@ -238,7 +239,7 @@ func TestRebase_OntoPropagatesToSubsequentBranches(t *testing.T) { // b4: first non-merged ancestor = b3 → newBase = b3 // RebaseOnto("b3", "b3-orig-sha", "b4") require.Len(t, rebaseCalls, 2) - assert.Equal(t, rebaseCall{"main", "b2-orig-sha", "b3"}, rebaseCalls[0], + assert.Equal(t, rebaseCall{"default-sha", "b2-orig-sha", "b3"}, rebaseCalls[0], "b3 should rebase --onto main with b2's SHA as oldBase") assert.Equal(t, rebaseCall{"b3", "b3-orig-sha", "b4"}, rebaseCalls[1], "b4 should rebase --onto b3 with b3's original SHA as oldBase") @@ -315,7 +316,7 @@ func TestRebase_StaleOntoOldBase_UsesForkPoint(t *testing.T) { require.Len(t, rebaseCalls, 2) // b2: stale ontoOldBase detected → uses fork-point(main, b2) - assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseCalls[0], + assert.Equal(t, rebaseCall{"default-sha", "main-b2-forkpoint", "b2"}, rebaseCalls[0], "b2 should use the reflog fork-point when ontoOldBase is stale") // b3: b2's SHA is a valid ancestor → uses it directly @@ -522,7 +523,7 @@ func TestRebase_DownstackOnly(t *testing.T) { assert.NoError(t, err) // b2 is at index 1, so downstack = [b1, b2] (indices 0..1) require.Len(t, allRebaseCalls, 2, "downstack should rebase b1 and b2 only") - assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "sha-main", allRebaseCalls[0].newBase, "b1 should be rebased onto the pinned trunk") assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") } @@ -630,7 +631,7 @@ func TestRebase_UpstackWithMergedBranchBelow(t *testing.T) { require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3") // b2: --onto rebase with b1's old SHA as old base - assert.Equal(t, "main", allRebaseCalls[0].newBase, "b2 should be rebased onto main (first non-merged ancestor)") + assert.Equal(t, "sha-main", allRebaseCalls[0].newBase, "b2 should be rebased onto the pinned main (first non-merged ancestor)") assert.Equal(t, "sha-b1", allRebaseCalls[0].oldBase, "b2 should use b1's original SHA as old base") assert.Equal(t, "b2", allRebaseCalls[0].branch, "b2 should be the branch being rebased") @@ -1500,7 +1501,7 @@ func TestRebase_SkipsMergedBranchesNotExistingLocally(t *testing.T) { // Head SHA as oldBase so `git rebase --onto` receives valid arguments. require.Len(t, rebaseCalls, 1) assert.Equal(t, "b2", rebaseCalls[0].branch) - assert.Equal(t, "main", rebaseCalls[0].newBase) + assert.Equal(t, "sha-main", rebaseCalls[0].newBase) assert.Equal(t, "b1-stored-head-sha", rebaseCalls[0].oldBase) } @@ -2258,3 +2259,223 @@ 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")) } + +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_WorktreesPreserveCheckouts(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + issue250WriteFile(t, repo.dir, "unrelated.txt", "leave main alone\n") + nested := filepath.Join(repo.childDir, "nested") + require.NoError(t, os.MkdirAll(nested, 0755)) + withIssue250Repo(t, nested) + cfg := issue250TestConfig(t) + + require.NoError(t, runRebase(cfg, &rebaseOptions{remote: "origin"})) + + 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")) + 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")) + data, err := os.ReadFile(filepath.Join(repo.dir, "unrelated.txt")) + require.NoError(t, err) + assert.Equal(t, "leave main alone\n", string(data)) + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + assert.Equal(t, issue250Git(t, repo.dir, "rev-parse", "parent"), sf.Stacks[0].Branches[1].Base) + _, err = os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRebase_WorktreesDirtyTargetFailsBeforeMutation(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + issue250WriteFile(t, repo.parentDir, "uncommitted.txt", "preserve me\n") + parentBefore := issue250Git(t, repo.dir, "rev-parse", "parent") + childBefore := issue250Git(t, repo.dir, "rev-parse", "child") + withIssue250Repo(t, repo.childDir) + cfg := issue250TestConfig(t) + + assert.ErrorIs(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), ErrSilent) + + assert.Equal(t, parentBefore, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) + assert.FileExists(t, filepath.Join(repo.parentDir, "uncommitted.txt")) + _, err := os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRebase_WorktreesConflictContinueFromDifferentWorktree(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), ErrConflict) + + state, err := loadRebaseState(repo.gitDir) + require.NoError(t, err) + require.NotNil(t, state.Worktrees) + assert.True(t, worktree.SamePath(repo.childDir, state.Worktrees.Location("child").Path)) + assert.False(t, git.IsRebaseInProgress(), "the initiating main worktree must remain usable") + assert.True(t, git.ForWorktree(repo.childDir).IsRebaseInProgress()) + + issue250WriteFile(t, repo.childDir, "base.txt", "resolved\n") + issue250Git(t, repo.childDir, "add", "base.txt") + withIssue250Repo(t, repo.parentDir) + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + + 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")) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) + _, err = os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRebase_WorktreesAbortRetainsRecoveryForNewEdits(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + parentBefore := issue250Git(t, repo.dir, "rev-parse", "parent") + childBefore := issue250Git(t, repo.dir, "rev-parse", "child") + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), ErrConflict) + parentRebased := issue250Git(t, repo.dir, "rev-parse", "parent") + require.NotEqual(t, parentBefore, parentRebased) + issue250WriteFile(t, repo.parentDir, "new-work.txt", "new work after conflict\n") + + withIssue250Repo(t, repo.childDir) + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{abort: true}), ErrSilent) + assert.Equal(t, parentRebased, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) + assert.FileExists(t, filepath.Join(repo.parentDir, "new-work.txt")) + assert.FileExists(t, filepath.Join(repo.gitDir, rebaseStateFile)) + + require.NoError(t, os.Remove(filepath.Join(repo.parentDir, "new-work.txt"))) + require.NoError(t, runRebase(cfg, &rebaseOptions{abort: true})) + assert.Equal(t, parentBefore, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.Equal(t, childBefore, issue250Git(t, repo.dir, "rev-parse", "child")) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + _, err := os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRebase_WorktreesUpstackDoesNotRequireDirtyLowerWorktree(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + parentBefore := issue250Git(t, repo.dir, "rev-parse", "parent") + issue250WriteFile(t, repo.parentDir, "unfinished.txt", "keep working\n") + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.NoError(t, runRebase(cfg, &rebaseOptions{branch: "child", upstack: true, noTrunk: true})) + + assert.Equal(t, parentBefore, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.FileExists(t, filepath.Join(repo.parentDir, "unfinished.txt")) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) +} + +func TestRebase_WorktreesExplicitTargetRecoverySelectsCorrectStack(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + issue250Git(t, repo.dir, "checkout", "-b", "independent", "main") + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + sf.AddStack(stack.Stack{Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "independent"}}}) + require.NoError(t, stack.Save(repo.gitDir, sf)) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{branch: "child", upstack: true, remote: "origin"}), ErrConflict) + issue250WriteFile(t, repo.childDir, "base.txt", "resolved\n") + issue250Git(t, repo.childDir, "add", "base.txt") + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + + assert.Equal(t, "independent", issue250Git(t, repo.dir, "branch", "--show-current")) + sf, err = stack.Load(repo.gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 2) + assert.Equal(t, []string{"parent", "child"}, sf.Stacks[0].BranchNames()) + assert.Equal(t, []string{"independent"}, sf.Stacks[1].BranchNames()) + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) +} + +func TestRebase_ContinueWithRemainingBranchInConflictWorktree(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + issue250Git(t, repo.dir, "worktree", "remove", repo.parentDir) + issue250Git(t, repo.dir, "worktree", "remove", repo.childDir) + issue250Git(t, repo.dir, "branch", "grandchild", "child") + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + child := issue250Git(t, repo.dir, "rev-parse", "child") + sf.Stacks[0].Branches = append(sf.Stacks[0].Branches, stack.BranchRef{Branch: "grandchild", Head: child, Base: child}) + require.NoError(t, stack.Save(repo.gitDir, sf)) + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runRebase(cfg, &rebaseOptions{remote: "origin"}), ErrConflict) + issue250WriteFile(t, repo.dir, "base.txt", "resolved\n") + issue250Git(t, repo.dir, "add", "base.txt") + require.NoError(t, runRebase(cfg, &rebaseOptions{cont: true})) + + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "child", "grandchild")) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + assert.False(t, git.IsRebaseInProgress()) + _, err = os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +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) +} diff --git a/cmd/rebase_worktree.go b/cmd/rebase_worktree.go new file mode 100644 index 00000000..4531f9d6 --- /dev/null +++ b/cmd/rebase_worktree.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "errors" + "fmt" + "slices" + + "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 rebaseBranchNames(branches []stack.BranchRef) []string { + var names []string + for _, branch := range branches { + if !branch.IsSkipped() { + names = append(names, branch.Branch) + } + } + return names +} + +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{ + 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 printWorktreeConflict(cfg *config.Config, state *rebaseState, base string) { + branch := state.Worktrees.Pending + if branch == "" { + branch = state.ConflictBranch + } + ops, err := state.Worktrees.Ops(branch) + if err != nil { + cfg.Errorf("%s", err) + return + } + printConflictDetailsAt(cfg, ops, state.Worktrees.Location(branch).Path, base, "gh stack rebase --continue") +} + +func rollbackWorktreeRebase(cfg *config.Config, dir string, state *rebaseState) error { + ctx := state.Worktrees + state.Phase = "restoring" + var err error + if ctx.Pending != "" { + ops, scopeErr := ctx.Ops(ctx.Pending) + if scopeErr != nil { + err = scopeErr + } else if ops.IsRebaseInProgress() { + err = ops.RebaseAbort() + } + } + if err == nil { + err = errors.Join(ctx.Restore(state.OriginalRefs), ctx.RestoreOrigin(state.OriginalBranch)) + } + if err == nil && state.OriginalStack != nil { + err = restoreWorktreeRebaseMetadata(dir, state) + } + if err != nil { + if saveErr := saveRebaseState(dir, state); saveErr != nil { + err = errors.Join(err, saveErr) + } + cfg.Errorf("Could not fully restore the stack; recovery state was retained: %v", err) + return err + } + if err := clearRebaseState(dir); err != nil { + cfg.Errorf("Branches restored, but could not clear recovery state: %v", err) + return err + } + return 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 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 continueWorktreeRebase(cfg *config.Config, dir string, state *rebaseState) error { + if state.Phase == "restoring" { + return fmt.Errorf("restoration is incomplete; run gh stack rebase --abort to finish restoring the stack") + } + sf, err := stack.Load(dir) + if err != nil { + return err + } + s, err := rebaseStackFromState(sf, state) + if err != nil { + return err + } + ctx := state.Worktrees + _ = syncStackPRs(cfg, s) + if state.Phase != "complete" { + remainingStart := state.CurrentBranchIndex + if ctx.Pending != "" { + remainingStart++ + } + if remainingStart > state.EndIndex { + return fmt.Errorf("invalid pending branch in saved rebase state") + } + required := rebaseBranchNames(s.Branches[remainingStart:state.EndIndex]) + if ctx.Pending != "" { + if _, err := ctx.Ops(ctx.Pending); err != nil { + return err + } + pendingPath := ctx.Location(ctx.Pending).Path + otherWorktrees := required[:0] + for _, branch := range required { + if _, err := ctx.Ops(branch); err != nil { + return err + } + if !worktree.SamePath(ctx.Location(branch).Path, pendingPath) { + otherWorktrees = append(otherWorktrees, branch) + } + } + required = otherWorktrees + } + // The pending worktree is intentionally busy. Its remaining branches + // are checked by Prepare after native continuation has completed. + if err := ctx.Preflight(required); err != nil { + return err + } + if ctx.Pending != "" { + branch := ctx.Pending + if state.CurrentBranchIndex >= len(s.Branches) || s.Branches[state.CurrentBranchIndex].Branch != branch { + return fmt.Errorf("saved conflict branch does not match the stack") + } + ops, err := ctx.Ops(branch) + if err != nil { + return err + } + retry := false + if ops.IsRebaseInProgress() { + if err := ops.RebaseContinue(git.RebaseOpts{CommitterDateIsAuthorDate: state.CommitterDateIsAuthorDate}); err != nil { + cfg.Errorf("rebase continue failed: %v", err) + printWorktreeConflict(cfg, state, state.RebaseBase) + return ErrConflict + } + } else { + sha, err := ops.RevParse(branch) + if err != nil { + return err + } + retry = state.Phase == "applying" && sha == ctx.PendingBefore + } + if retry { + opts := savedCascadeOpts(cfg, dir, state, s) + conflicted, err := rebaseStep(opts, branch, state.CurrentBranchIndex, state.RebaseBase, state.RebaseOldBase, state.RebaseOnto, state.UseOnto) + if err != nil { + cfg.Errorf("%s", err) + if conflicted { + printWorktreeConflict(cfg, state, state.RebaseBase) + return ErrConflict + } + return ErrSilent + } + } else { + containsBase, err := ops.IsAncestor(state.RebaseBase, branch) + if err != nil || !containsBase { + return fmt.Errorf("%s does not contain its saved rebase target; the Git rebase may have been aborted; run gh stack rebase --abort", branch) + } + if err := ctx.Record(branch); err != nil { + return err + } + state.ConflictBranch = "" + state.CurrentBranchIndex++ + state.Phase = "applying" + if err := saveRebaseState(dir, state); err != nil { + return err + } + } + cfg.Successf("Rebased %s", branch) + } + result := cascadeRebase(savedCascadeOpts(cfg, dir, state, s)) + if result.Err != nil { + cfg.Errorf("%s", result.Err) + _ = rollbackWorktreeRebase(cfg, dir, state) + return ErrSilent + } + if result.Conflicted { + printWorktreeConflict(cfg, state, result.ConflictBase) + return ErrConflict + } + } + if err := finishWorktreeRebase(cfg, dir, state, sf, s); err != nil { + return err + } + cfg.Successf("All branches in stack rebased locally") + cfg.Printf("To push your changes, run `%s`", cfg.ColorCyan("gh stack push")) + return nil +} + +func savedCascadeOpts(cfg *config.Config, dir string, state *rebaseState, s *stack.Stack) cascadeRebaseOpts { + return cascadeRebaseOpts{ + Cfg: cfg, + Stack: s, + Branches: s.Branches[state.CurrentBranchIndex:state.EndIndex], + StartAbsIdx: state.CurrentBranchIndex, + OriginalRefs: state.OriginalRefs, + NeedsOnto: state.UseOnto, + OntoOldBase: state.OntoOldBase, + CommitterDateIsAuthorDate: state.CommitterDateIsAuthorDate, + TrunkRef: state.TrunkRef, + TrunkSHA: state.TrunkSHA, + Worktrees: state.Worktrees, + State: state, + StateDir: dir, + } +} + +func finishWorktreeRebase(cfg *config.Config, dir string, state *rebaseState, sf *stack.StackFile, s *stack.Stack) error { + if err := state.Worktrees.RestoreOrigin(state.OriginalBranch); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + target := state.TrunkSHA + if target == "" { + target = state.TrunkRef + } + if unstacked := verifyStacked(s, target, state.StartIndex, state.EndIndex); len(unstacked) > 0 { + reportUnstacked(cfg, state.TrunkRef, unstacked) + _ = rollbackWorktreeRebase(cfg, dir, state) + return ErrSilent + } + _ = syncStackPRs(cfg, s) + return publishCompletedRebase(cfg, dir, state, sf, s) +} + +func publishCompletedRebase(cfg *config.Config, dir string, state *rebaseState, sf *stack.StackFile, s *stack.Stack) error { + updateBaseSHAsWithTrunk(s, state.TrunkSHA) + state.Phase = "complete" + state.CurrentBranchIndex = state.EndIndex + if err := saveRebaseState(dir, state); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + 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: %v", err) + return ErrSilent + } + return nil +} diff --git a/cmd/submit.go b/cmd/submit.go index cf4f29c3..e528c886 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) @@ -179,6 +183,10 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { return ErrSilent } // DeleteStack or other failure — don't continue with stale state + var exitErr *ExitError + if errors.As(err, &exitErr) { + return err + } return ErrSilent } } @@ -241,9 +249,9 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { } // Create or update the stack on GitHub + stackSynced := false if stacksAvailable { - syncStack(cfg, client, s) - clearPendingModifyState(cfg, gitDir) + stackSynced = syncStack(cfg, client, s) } // Update base commit hashes and sync PR state @@ -251,7 +259,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { _ = syncStackPRs(cfg, s) if err := stack.Save(gitDir, sf); err != nil { - return handleSaveError(cfg, err) + return stackSaveError(cfg, err) + } + if stackSynced { + if err := clearPendingModifyState(cfg, s, gitDir); err != nil { + return err + } } cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) @@ -626,11 +639,19 @@ func mergedPRNumbers(s *stack.Stack) map[int]bool { // succeeds, ensuring retry safety. func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.Stack, gitDir string) error { state, err := modify.LoadState(gitDir) - if err != nil || state == nil { + if err != nil { + cfg.Errorf("reading modify recovery state: %s", err) + return ErrModifyRecovery + } + if state == nil { return 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 ErrModifyRecovery + } + if !modify.MatchesStack(state, s) { + return nil } // Prompt for confirmation before overwriting the remote stack @@ -661,7 +682,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S } 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") @@ -670,25 +691,44 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) return 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 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 ErrModifyRecovery + } + s.ID = "" + s.Number = 0 return 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 +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 + } + 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 } - modify.ClearState(gitDir) cfg.Successf("Stack recreated on GitHub to match local state") + 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 74cf8a17..0a9a38f1 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -1824,6 +1824,136 @@ func newPendingSubmitState(priorStackID string) *modify.StateFile { } } +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 + }, + } + require.NoError(t, handlePendingModify(cfg, client, s, dir)) + 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() + assert.ErrorIs(t, handlePendingModify(cfg, &github.MockClient{}, s, dir), ErrModifyRecovery) + 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 + }, + } + require.NoError(t, handlePendingModify(cfg, client, s, dir)) + 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 }, + } + assert.ErrorIs(t, handlePendingModify(cfg, client, s, dir), ErrConflict) + 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() @@ -1901,7 +2031,7 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) { defer cfg.Err.Close() err := handlePendingModify(cfg, client, s, gitDir) - assert.NoError(t, err) + assert.ErrorIs(t, err, ErrModifyRecovery) 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") } @@ -1970,7 +2100,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 +2113,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)) } diff --git a/cmd/switch.go b/cmd/switch.go index 80e72e17..473a1802 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 d6262042..da98f141 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 c9aeda7b..32883a43 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" ) @@ -75,9 +76,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 +95,7 @@ func runSync(cfg *config.Config, opts *syncOptions) error { sf := result.StackFile s := result.Stack currentBranch := result.CurrentBranch + originalTrunk := s.Trunk.Branch // Resolve remote once for fetch and push remote, err := pickRemote(cfg, currentBranch, opts.remote) @@ -140,31 +147,67 @@ func runSync(cfg *config.Config, opts *syncOptions) error { if cb, cbErr := git.CurrentBranch(); cbErr == nil { currentBranch = cb } + _ = syncStackPRs(cfg, s) + ctx, err := worktree.New() + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + planned := planFastForwardBranches(s, remote) // --- Step 2: Resolve trunk --- - trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch) + trunk, err := resolveTrunkTarget(cfg, s, remote, currentBranch, trunkResolveOptions{ + Worktrees: ctx, + Preflight: func(sha string, moved bool) error { + var required []string + for _, forward := range planned { + required = append(required, forward.Branch) + } + if moved || len(planned) > 0 || stackNeedsRebase(s, sha) { + required = append(required, activeBranchNames(s)...) + } + if err := ctx.Preflight(required); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } + return nil + }, + }) if err != nil { return err } // --- Step 2b: Fast-forward stack branches behind their remote tracking branch --- - updatedBranches := fastForwardBranches(cfg, s, remote, currentBranch) + updatedBranches, err := fastForwardBranches(cfg, planned, ctx) + if err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } // --- Step 3: Cascade rebase --- 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 ...") - // Sync PR state to detect merged PRs before rebasing. - _ = syncStackPRs(cfg, s) - originalRefs, err = resolveOriginalRefs(s) if err != nil { - cfg.Warningf("Could not resolve branch SHAs — skipping rebase: %v", err) + 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, @@ -172,35 +215,33 @@ func runSync(cfg *config.Config, opts *syncOptions) error { StartAbsIdx: 0, OriginalRefs: originalRefs, TrunkRef: trunk.Ref, + TrunkSHA: trunk.SHA, + Worktrees: ctx, + State: state, + StateDir: gitDir, }) if result.Err != nil { cfg.Errorf("%v", result.Err) - if result.Rebased { - restoreRebaseRefs(cfg, currentBranch, originalRefs) - } else { - _ = git.CheckoutBranch(currentBranch) - } - stack.SaveNonBlocking(gitDir, sf) + _ = rollbackWorktreeRebase(cfg, gitDir, state) return ErrSilent } if result.Conflicted { // Abort and restore everything — sync is non-interactive. - if git.IsRebaseInProgress() { - _ = git.RebaseAbort() - } - restoreErrors := restoreBranches(originalRefs) - _ = git.CheckoutBranch(currentBranch) - cfg.Errorf("Conflict detected rebasing %s onto %s", result.ConflictBranch, result.ConflictBase) - reportRestoreStatus(cfg, restoreErrors) + if err := rollbackWorktreeRebase(cfg, gitDir, state); err != nil { + return ErrSilent + } + cfg.Printf("Branches restored to their pre-rebase state") 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) + if err := stack.Save(gitDir, sf); err != nil { + cfg.Warningf("Could not save refreshed PR metadata: %v", err) + } return ErrConflict } @@ -208,18 +249,24 @@ func runSync(cfg *config.Config, opts *syncOptions) error { rebased = true } } - _ = git.CheckoutBranch(currentBranch) + if err := ctx.RestoreOrigin(currentBranch); err != nil { + cfg.Errorf("%s", err) + return ErrSilent + } } - if unstacked := verifyStacked(s, trunk.Ref, 0, len(s.Branches)); len(unstacked) > 0 { - _ = git.CheckoutBranch(currentBranch) + if unstacked := verifyStacked(s, trunk.SHA, 0, len(s.Branches)); len(unstacked) > 0 { reportUnstacked(cfg, trunk.Ref, unstacked) - if rebased && originalRefs != nil { - restoreRebaseRefs(cfg, currentBranch, originalRefs) + if state != nil { + _ = rollbackWorktreeRebase(cfg, gitDir, state) } - stack.SaveNonBlocking(gitDir, sf) return ErrSilent } + if state != nil { + if err := publishCompletedRebase(cfg, gitDir, state, sf, s); err != nil { + return err + } + } // --- Step 4: Push --- cfg.Printf("") @@ -346,14 +393,24 @@ func runSync(cfg *config.Config, opts *syncOptions) error { } } if needsSwitch { - switchTarget := trunk.Branch + switchTarget := "" for _, b := range s.Branches { if !b.IsSkipped() { + if owner := ctx.Owners[b.Branch]; owner != nil && !worktree.SamePath(owner.Path, ctx.Origin.Path) { + continue + } switchTarget = b.Branch break } } - if err := git.CheckoutBranch(switchTarget); err != nil { + if switchTarget == "" { + if owner := ctx.Owners[trunk.Branch]; owner == nil || worktree.SamePath(owner.Path, ctx.Origin.Path) { + switchTarget = trunk.Branch + } + } + if switchTarget == "" { + cfg.Infof("Keeping %s: no available checkout destination", currentBranch) + } else if err := git.CheckoutBranch(switchTarget); err != nil { cfg.Warningf("Failed to switch from %s to %s: %v", currentBranch, switchTarget, err) } else { currentBranch = switchTarget @@ -363,6 +420,14 @@ func runSync(cfg *config.Config, opts *syncOptions) error { cfg.Printf("") pruned := 0 for _, name := range prunable { + if owner := ctx.Owners[name]; owner != nil && !worktree.SamePath(owner.Path, ctx.Origin.Path) { + cfg.Infof("Keeping %s: checked out in worktree %s", name, owner.Path) + continue + } + if name == currentBranch { + cfg.Infof("Keeping %s: still checked out", name) + continue + } if err := git.DeleteBranch(name, true); err != nil { cfg.Warningf("Failed to delete %s: %v", name, err) } else { @@ -382,12 +447,15 @@ func runSync(cfg *config.Config, opts *syncOptions) error { // the local branch was already deleted. This prevents // `git checkout ` from resurrecting the branch. for _, b := range merged { + if owner := ctx.Owners[b.Branch]; owner != nil && !worktree.SamePath(owner.Path, ctx.Origin.Path) { + continue + } _ = git.DeleteTrackingRef(remote, b.Branch) } } // --- 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 ee364985..82bc05ab 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -3,6 +3,8 @@ package cmd import ( "fmt" "io" + "os" + "path/filepath" "strings" "testing" @@ -482,6 +484,7 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { } mock := newSyncMock(tmpDir, "b1") + mock.CurrentBranchFn = func() (string, error) { return currentBranch, nil } mock.RevParseFn = func(ref string) (string, error) { if ref == "main" { return "local-sha", nil @@ -492,12 +495,21 @@ func TestSync_RebaseConflict_RestoresAll(t *testing.T) { if sha, ok := branchSHAs[ref]; ok { return sha, nil } + if sha, ok := branchSHAs[strings.TrimPrefix(ref, "origin/")]; ok { + return sha, nil + } return "sha-" + ref, nil } mock.IsAncestorFn = func(a, d string) (bool, error) { return true, nil } - mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.UpdateBranchRefFn = func(branch, sha string) error { + if _, ok := branchSHAs[branch]; ok { + resets = append(resets, resetCall{branch, sha}) + branchSHAs[branch] = sha + } + return nil + } mock.CheckoutBranchFn = func(name string) error { checkouts = append(checkouts, name) currentBranch = name @@ -755,7 +767,7 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) { // b2: first active branch after merged → RebaseOnto(main, b1-orig-sha, b2) // b3: normal --onto → RebaseOnto(b2, b2-orig-sha, b3) require.Len(t, rebaseOntoCalls, 2) - assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseOntoCalls[0]) + assert.Equal(t, rebaseCall{"remote-sha", "b1-orig-sha", "b2"}, rebaseOntoCalls[0]) assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseOntoCalls[1]) // Push should use force (rebase happened) @@ -919,7 +931,7 @@ func TestSync_StaleOntoOldBase_UsesForkPoint(t *testing.T) { require.Len(t, rebaseOntoCalls, 2) // b2: stale ontoOldBase → uses fork-point(main, b2) - assert.Equal(t, rebaseCall{"main", "main-b2-forkpoint", "b2"}, rebaseOntoCalls[0], + assert.Equal(t, rebaseCall{"remote-sha", "main-b2-forkpoint", "b2"}, rebaseOntoCalls[0], "b2 should use the reflog fork-point when ontoOldBase is stale") // b3: b2's SHA is a valid ancestor → uses it directly @@ -1061,7 +1073,7 @@ func TestSync_BranchFastForward_TriggersRebase(t *testing.T) { // b1 should be fast-forwarded via MergeFF (since we're on b1) require.Len(t, mergeFFCalls, 1, "should fast-forward b1 via MergeFF") - assert.Equal(t, "origin/b1", mergeFFCalls[0]) + assert.Equal(t, "b1-remote-sha", mergeFFCalls[0]) assert.Contains(t, output, "Fast-forwarded b1") // Cascade rebase should be triggered (even though trunk didn't move) @@ -1239,7 +1251,7 @@ func TestSync_MergedBranchDeletedFromRemote(t *testing.T) { // Head SHA as oldBase so `git rebase --onto` receives valid arguments. require.Len(t, rebaseOntoCalls, 1) assert.Equal(t, "b2", rebaseOntoCalls[0].branch) - assert.Equal(t, "main", rebaseOntoCalls[0].newBase) + assert.Equal(t, "remote-sha", rebaseOntoCalls[0].newBase) assert.Equal(t, "b1-stored-head-sha", rebaseOntoCalls[0].oldBase) } @@ -2580,3 +2592,56 @@ func TestSync_MergedBranchPruned_NoFalseDivergence(t *testing.T) { assert.Empty(t, created) assert.NotContains(t, output, "diverged") } + +func TestSync_WorktreesPublishesFromLinkedWorktree(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + withIssue250Repo(t, repo.childDir) + cfg := issue250TestConfig(t) + + require.NoError(t, runSync(cfg, &syncOptions{remote: "origin"})) + + 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")) + for _, branch := range []string{"parent", "child"} { + assert.Equal(t, issue250Git(t, repo.dir, "rev-parse", branch), issue250Git(t, repo.dir, "rev-parse", "origin/"+branch)) + } + require.NoError(t, issue250GitMayFail(t, repo.dir, "merge-base", "--is-ancestor", "parent", "child")) +} + +func TestSync_WorktreesConflictRestoresWithoutPush(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, true) + beforeParent := issue250Git(t, repo.dir, "rev-parse", "parent") + beforeChild := issue250Git(t, repo.dir, "rev-parse", "child") + remoteParent := issue250Git(t, repo.dir, "rev-parse", "origin/parent") + remoteChild := issue250Git(t, repo.dir, "rev-parse", "origin/child") + withIssue250Repo(t, repo.dir) + cfg := issue250TestConfig(t) + + require.ErrorIs(t, runSync(cfg, &syncOptions{remote: "origin"}), ErrConflict) + + assert.Equal(t, beforeParent, issue250Git(t, repo.dir, "rev-parse", "parent")) + assert.Equal(t, beforeChild, issue250Git(t, repo.dir, "rev-parse", "child")) + assert.Equal(t, remoteParent, issue250Git(t, repo.dir, "rev-parse", "origin/parent")) + assert.Equal(t, remoteChild, issue250Git(t, repo.dir, "rev-parse", "origin/child")) + assert.False(t, git.ForWorktree(repo.childDir).IsRebaseInProgress()) + assert.Equal(t, "main", issue250Git(t, repo.dir, "branch", "--show-current")) + _, err := os.Stat(filepath.Join(repo.gitDir, rebaseStateFile)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestSync_WorktreesPruneKeepsOccupiedMergedBranch(t *testing.T) { + repo := setupWorktreeRebaseRepo(t, false) + sf, err := stack.Load(repo.gitDir) + require.NoError(t, err) + sf.Stacks[0].Branches[0].PullRequest = &stack.PullRequestRef{Number: 101, Merged: true} + require.NoError(t, stack.Save(repo.gitDir, sf)) + withIssue250Repo(t, repo.childDir) + cfg := issue250TestConfig(t) + + require.NoError(t, runSync(cfg, &syncOptions{remote: "origin", prune: true})) + + assert.Equal(t, "parent", issue250Git(t, repo.parentDir, "branch", "--show-current")) + require.NoError(t, issue250GitMayFail(t, repo.dir, "show-ref", "--verify", "refs/heads/parent")) + assert.DirExists(t, repo.parentDir) +} diff --git a/cmd/trunk.go b/cmd/trunk.go index 4e6fc63e..714324f3 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,11 +49,27 @@ 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. if !git.BranchExists(trunk) { + 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) { @@ -57,9 +83,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/trunk_target_test.go b/cmd/trunk_target_test.go index ef864824..582e8443 100644 --- a/cmd/trunk_target_test.go +++ b/cmd/trunk_target_test.go @@ -273,6 +273,12 @@ func TestRebase_LaterStartErrorRestoresEarlierBranches(t *testing.T) { var resets []resetCall mock := newRebaseMock(tmpDir, currentBranch) + mock.CurrentBranchFn = func() (string, error) { return currentBranch, nil } + mock.UpdateBranchRefFn = func(branch, sha string) error { + resets = append(resets, resetCall{branch, sha}) + branchSHAs[branch] = sha + return nil + } mock.BranchExistsFn = func(string) bool { return true } mock.RevParseFn = func(ref string) (string, error) { if ref == "main" || ref == "origin/main" { @@ -332,6 +338,11 @@ func TestSync_LaterStartErrorRestoresEarlierBranches(t *testing.T) { pushes := 0 mock := newSyncMock(tmpDir, currentBranch) + mock.CurrentBranchFn = func() (string, error) { return currentBranch, nil } + mock.UpdateBranchRefFn = func(branch, sha string) error { + branchSHAs[branch] = sha + return nil + } mock.RevParseFn = func(ref string) (string, error) { if ref == "main" || ref == "origin/main" { return "trunk", nil @@ -495,7 +506,7 @@ func TestSync_UnstackedCascadeDoesNotPush(t *testing.T) { if a == "local" && d == "remote" { return true, nil } - if a == "main" && d == "b1" { + if (a == "main" || a == "remote") && d == "b1" { return false, nil } return true, nil diff --git a/cmd/unstack.go b/cmd/unstack.go index e0251a05..db2324d0 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 04817c63..87dfa237 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 65b198e3..bc74fc01 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -17,6 +17,7 @@ import ( "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" "github.com/github/gh-stack/internal/theme" + "github.com/github/gh-stack/internal/worktree" ) // ErrSilent indicates the error has already been printed to the user. @@ -215,10 +216,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 +287,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 { @@ -418,7 +421,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 +431,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 +448,32 @@ 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()) } + // Read-only selection must remain usable while another operation is paused. + if cfg.StackMutation == nil { + 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 +797,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 +834,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 +859,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. @@ -841,8 +884,15 @@ func activeBranchNames(s *stack.Stack) []string { // tracking branch when the local branch is strictly behind. Returns the names // of branches that were updated. Branches that are up-to-date, diverged, or // have no remote tracking branch are silently skipped. -func fastForwardBranches(cfg *config.Config, s *stack.Stack, remote, currentBranch string) []string { - var updated []string +type branchFastForward struct { + Branch string + RemoteRef string + OldSHA string + NewSHA string +} + +func planFastForwardBranches(s *stack.Stack, remote string) []branchFastForward { + var planned []branchFastForward for _, br := range s.Branches { if br.IsSkipped() { continue @@ -867,23 +917,40 @@ func fastForwardBranches(cfg *config.Config, s *stack.Stack, remote, currentBran continue } - // Local is behind remote — fast-forward. - if currentBranch == br.Branch { - if err := git.MergeFF(remoteRef); err != nil { - cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) - continue + planned = append(planned, branchFastForward{br.Branch, remoteRef, localSHA, remoteSHA}) + } + return planned +} + +func fastForwardBranches(cfg *config.Config, planned []branchFastForward, ctx *worktree.Context) ([]string, error) { + var updated []string + for _, plan := range planned { + ops, err := ctx.Ops(plan.Branch) + if err != nil { + return updated, err + } + if sha, err := ops.RevParse(plan.Branch); err != nil || sha != plan.OldSHA { + return updated, fmt.Errorf("%s changed since fast-forward preflight; retry the command", plan.Branch) + } + current, err := ops.CurrentBranch() + if err != nil { + return updated, err + } + if current == plan.Branch { + if err := worktree.CheckClean(ops, ctx.Location(plan.Branch).Path); err != nil { + return updated, err } + err = ops.MergeFF(plan.NewSHA) } else { - if err := git.UpdateBranchRef(br.Branch, remoteSHA); err != nil { - cfg.Warningf("Failed to fast-forward %s from remote: %v", br.Branch, err) - continue - } + err = ops.UpdateBranchRef(plan.Branch, plan.NewSHA) } - - cfg.Successf("Fast-forwarded %s to %s", br.Branch, short(remoteSHA)) - updated = append(updated, br.Branch) + if err != nil { + return updated, fmt.Errorf("fast-forwarding %s: %w", plan.Branch, err) + } + cfg.Successf("Fast-forwarded %s to %s", plan.Branch, short(plan.NewSHA)) + updated = append(updated, plan.Branch) } - return updated + return updated, nil } // resolveOriginalRefs builds a map from branch name to current SHA for all @@ -962,6 +1029,11 @@ type trunkTarget struct { Moved bool } +type trunkResolveOptions struct { + Worktrees *worktree.Context + Preflight func(string, bool) error +} + func (t trunkTarget) Describe() string { return fmt.Sprintf("%s (%s)", t.Ref, short(t.SHA)) } @@ -969,14 +1041,30 @@ func (t trunkTarget) Describe() string { // resolveTrunkTarget fetches the trunk explicitly, then returns the ref the // cascade must use. Updating the local trunk is best-effort; the fetched remote // ref remains the source of truth when the local branch is stale or immovable. -func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranch string) (trunkTarget, error) { +func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranch string, options ...trunkResolveOptions) (trunkTarget, error) { + var opts trunkResolveOptions + if len(options) > 0 { + opts = options[0] + } + checked := false + preflight := func(sha string, moved bool) error { + if checked || opts.Preflight == nil { + return nil + } + checked = true + return opts.Preflight(sha, moved) + } normalizeStackTrunk(cfg, s, remote) trunk := s.Trunk.Branch remoteRef := remote + "/" + trunk if err := git.FetchBranch(remote, trunk); err != nil { if errors.Is(err, git.ErrRemoteBranchNotFound) { - return trunkWithoutRemote(cfg, trunk, remote) + target, err := trunkWithoutRemote(cfg, trunk, remote) + if err == nil { + err = preflight(target.SHA, target.Moved) + } + return target, err } cfg.Errorf("failed to fetch trunk branch %s from %s: %v", trunk, remote, err) return trunkTarget{}, ErrSilent @@ -990,6 +1078,9 @@ func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranc cfg.Successf("Fetched latest %s from %s", trunk, remote) if !git.BranchExists(trunk) { + if err := preflight(remoteSHA, true); err != nil { + return trunkTarget{}, err + } if err := git.CreateBranch(trunk, remoteRef); err != nil { cfg.Errorf("could not create local trunk branch %s from %s: %v", trunk, remoteRef, err) return trunkTarget{}, ErrSilent @@ -1004,17 +1095,35 @@ func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranc return trunkTarget{}, ErrSilent } if localSHA == remoteSHA { + if err := preflight(localSHA, false); err != nil { + return trunkTarget{}, err + } cfg.Successf("Trunk %s is already up to date", trunk) return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } canFastForward, ffErr := git.IsAncestor(localSHA, remoteSHA) if ffErr == nil && canFastForward { + if err := preflight(remoteSHA, true); err != nil { + return trunkTarget{}, err + } var updateErr error - if currentBranch == trunk { - updateErr = git.MergeFF(remoteRef) - } else { - updateErr = git.UpdateBranchRef(trunk, remoteSHA) + ops := git.CurrentOps() + if opts.Worktrees != nil { + ops, updateErr = opts.Worktrees.Ops(trunk) + if updateErr == nil { + currentBranch, updateErr = ops.CurrentBranch() + if updateErr == nil && currentBranch == trunk { + updateErr = worktree.CheckClean(ops, opts.Worktrees.Location(trunk).Path) + } + } + } + if updateErr == nil { + if currentBranch == trunk { + updateErr = ops.MergeFF(remoteRef) + } else { + updateErr = ops.UpdateBranchRef(trunk, remoteSHA) + } } if updateErr == nil { cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) @@ -1026,12 +1135,18 @@ func resolveTrunkTarget(cfg *config.Config, s *stack.Stack, remote, currentBranc } else if isAncestor, ancErr := git.IsAncestor(remoteSHA, localSHA); ancErr == nil && isAncestor { // Keep unpushed local trunk commits when they already contain the // fetched remote tip. + if err := preflight(localSHA, false); err != nil { + return trunkTarget{}, err + } cfg.Successf("Trunk %s is ahead of %s — using the local branch", trunk, remoteRef) return trunkTarget{Branch: trunk, Ref: trunk, SHA: localSHA}, nil } else { cfg.Warningf("Local %s has diverged from %s", trunk, remoteRef) } + if err := preflight(remoteSHA, false); err != nil { + return trunkTarget{}, err + } cfg.Printf(" Rebasing the stack onto %s instead; local %s is unchanged.", remoteRef, trunk) return trunkTarget{Branch: trunk, Ref: remoteRef, SHA: remoteSHA}, nil } @@ -1069,6 +1184,10 @@ type cascadeRebaseOpts struct { OntoOldBase string CommitterDateIsAuthorDate bool TrunkRef string + TrunkSHA string + Worktrees *worktree.Context + State *rebaseState + StateDir string } func (o cascadeRebaseOpts) trunkRef() string { @@ -1120,6 +1239,76 @@ type cascadeRebaseResult struct { OntoOldBase string // ontoOldBase at the conflict point (for --continue) } +func rebaseStep(opts cascadeRebaseOpts, branch string, absIdx int, base, oldBase string, useOnto, needsOnto bool) (bool, error) { + executionBase := base + if opts.TrunkSHA != "" && base == opts.trunkRef() { + executionBase = opts.TrunkSHA + } + if opts.Worktrees != nil { + if err := opts.Worktrees.Start(branch, opts.OriginalRefs[branch]); err != nil { + return false, err + } + } + if state := opts.State; state != nil { + state.Phase = "applying" + state.CurrentBranchIndex = absIdx + state.ConflictBranch = branch + state.RemainingBranches = nil + for _, remaining := range opts.Branches { + if opts.Stack.IndexOf(remaining.Branch) > absIdx { + state.RemainingBranches = append(state.RemainingBranches, remaining.Branch) + } + } + state.UseOnto = needsOnto + state.OntoOldBase = opts.OriginalRefs[branch] + state.RebaseBase = executionBase + state.RebaseOldBase = oldBase + state.RebaseOnto = useOnto + if err := saveRebaseState(opts.StateDir, state); err != nil { + return false, err + } + } + ops := git.CurrentOps() + var err error + if opts.Worktrees != nil { + ops, err = opts.Worktrees.Prepare(branch) + } else if !useOnto { + err = ops.CheckoutBranch(branch) + } + if err != nil { + return false, fmt.Errorf("preparing %s: %w", branch, err) + } + rebaseOpts := git.RebaseOpts{CommitterDateIsAuthorDate: opts.CommitterDateIsAuthorDate} + if useOnto { + err = ops.RebaseOnto(executionBase, oldBase, branch, rebaseOpts) + } else { + err = ops.Rebase(executionBase, rebaseOpts) + } + if err != nil { + conflicted := !git.IsRebaseStartError(err) + if conflicted && opts.State != nil { + opts.State.Phase = "conflict" + if saveErr := saveRebaseState(opts.StateDir, opts.State); saveErr != nil { + return false, errors.Join(err, saveErr) + } + } + return conflicted, err + } + if opts.Worktrees != nil { + if err := opts.Worktrees.Record(branch); err != nil { + return false, fmt.Errorf("recording completed rebase of %s: %w", branch, err) + } + } + if opts.State != nil { + opts.State.CurrentBranchIndex = absIdx + 1 + opts.State.ConflictBranch = "" + if err := saveRebaseState(opts.StateDir, opts.State); err != nil { + return false, err + } + } + return false, nil +} + // cascadeRebase performs a cascade rebase across the given branch range. It // stops at the first conflict and returns a result describing what happened. // The caller is responsible for conflict recovery (abort+restore or save state). @@ -1130,7 +1319,6 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { ontoOldBase := opts.OntoOldBase originalRefs := opts.OriginalRefs result := cascadeRebaseResult{} - rebaseOpts := git.RebaseOpts{CommitterDateIsAuthorDate: opts.CommitterDateIsAuthorDate} trunkRef := opts.trunkRef() for i, br := range opts.Branches { @@ -1183,8 +1371,8 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { } } - if err := git.RebaseOnto(newBase, actualOldBase, br.Branch, rebaseOpts); err != nil { - if git.IsRebaseStartError(err) { + if conflicted, err := rebaseStep(opts, br.Branch, absIdx, newBase, actualOldBase, true, true); err != nil { + if !conflicted { return cascadeRebaseResult{ Rebased: result.Rebased, Err: fmt.Errorf("could not start rebase of %s onto %s: %w", br.Branch, newBase, err), @@ -1211,6 +1399,7 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { ontoOldBase = originalRefs[br.Branch] } else { var rebaseErr error + var conflicted bool if absIdx > 0 { oldBase, err := resolveRebaseOldBase(originalRefs[base], br.Base, base, br.Branch) if err != nil { @@ -1219,19 +1408,13 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { Err: err, } } - rebaseErr = git.RebaseOnto(base, oldBase, br.Branch, rebaseOpts) + conflicted, rebaseErr = rebaseStep(opts, br.Branch, absIdx, base, oldBase, true, false) } else { - if err := git.CheckoutBranch(br.Branch); err != nil { - return cascadeRebaseResult{ - Rebased: result.Rebased, - Err: fmt.Errorf("checking out %s: %w", br.Branch, err), - } - } - rebaseErr = git.Rebase(base, rebaseOpts) + conflicted, rebaseErr = rebaseStep(opts, br.Branch, absIdx, base, "", false, false) } if rebaseErr != nil { - if git.IsRebaseStartError(rebaseErr) { + if !conflicted { return cascadeRebaseResult{ Rebased: result.Rebased, Err: fmt.Errorf("could not start rebase of %s onto %s: %w", br.Branch, base, rebaseErr), @@ -1860,7 +2043,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 464e6196..f7decaff 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,756 @@ 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 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, journal) +} + +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"} { + 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 { + return &git.MockOps{CommonDirFn: func() (string, error) { return root, nil }} + } + } + 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) + }) + } +} + +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 057de60f..27b2c4b1 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 a7a6e0b6..ff640506 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 00000000..c38abc4a --- /dev/null +++ b/cmd/worktree_utils.go @@ -0,0 +1,457 @@ +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} + 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 = os.ReadFile(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) + } + ownerCommon, err := git.ForWorktree(owner).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 +} + +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 + } + if git.IsRebaseInProgress() || git.IsCherryPickInProgress() { + 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 37e29d8a..9c7af724 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` automatically update affected clean owners. 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 f770be4e..b5e6e6d0 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,8 @@ 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. + ## After modifying If a stack of PRs has been created on GitHub, run: @@ -94,7 +102,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 +119,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 the second layer; 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 08f5f9e2..0b193568 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -5,6 +5,74 @@ 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. + +### 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` automatically operate in each affected branch's clean owning worktree. Unoccupied branches are processed in the initiating worktree, whose original checkout is restored afterward. Dirty, busy, missing, or changed affected owners block mutation; unrelated worktrees are left alone. A clean trunk owner can be fast-forwarded, while an unsafe local trunk retains the fetched-remote fallback. gh-stack never auto-stashes, transfers ownership, or creates/removes worktrees. + +Resolve and stage conflicts in the worktree named by the diagnostic. You can run `gh stack rebase --continue` or `--abort` from any linked worktree: the shared journal routes recovery to the recorded owners. `sync` still restores its cascade on conflicts rather than pushing partial results; completed fetches and earlier fast-forwards are outside that rollback boundary. Recovery retains state and reports any partial failure rather than discarding later edits or claiming a full restoration. Pruning skips branches still occupied in other worktrees. + +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 +277,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 e0e97589..2d8ea597 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` automatically update affected clean owners; dirty, busy, missing, or changed owners block unsafe updates. Neither command auto-stashes or manages worktree creation/removal. Shared-journal `--continue` and `--abort` use the recorded worktree, not the caller's checkout. Partial recovery failures retain state. See [Working across Git worktrees](/gh-stack/guides/workflows/#working-across-git-worktrees) for adoption, migration, and recovery 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 594b57cb..fb4356c6 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,11 @@ type Config struct { RepoOverride *repository.Repository } +type StackMutationContext struct { + CommonDir string + StateDir string +} + // New creates a new Config with terminal-aware output and color support. func New() *Config { terminal := term.FromEnv() @@ -172,7 +186,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/git.go b/internal/git/git.go index 180083f6..363703c6 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -5,14 +5,14 @@ import ( "errors" "fmt" "os" - "os/exec" + "path/filepath" "strings" "time" cligit "github.com/cli/cli/v2/git" ) -// client is a shared git client used by all package-level functions. +// client is used by unscoped operations. Scoped operations keep their own copy. var client = &cligit.Client{} // ErrMultipleRemotes is returned by ResolveRemote when multiple remotes @@ -33,22 +33,58 @@ type CommitInfo struct { Time time.Time } -// run executes an arbitrary git command via the client and returns trimmed stdout. -func run(args ...string) (string, error) { - cmd, err := client.Command(context.Background(), args...) +func (d *defaultOps) command(args ...string) (*cligit.Command, error) { + c, err := d.gitClient() if err != nil { - return "", err + return nil, err } - out, err := cmd.Output() + cmd, err := c.Command(context.Background(), args...) + if err != nil { + return nil, err + } + d.configureCommand(cmd) + return cmd, nil +} + +func (d *defaultOps) configureCommand(cmd *cligit.Command) { + if !d.scoped { + return + } + env := cmd.Environ() + cmd.Env = make([]string, 0, len(env)) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + switch strings.ToUpper(key) { + case "GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_PREFIX", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", + "GIT_IMPLICIT_WORK_TREE", "GIT_NAMESPACE", "GIT_CONFIG": + // An explicit target must not inherit another checkout's index, + // object database, or working directory from the caller. + continue + } + cmd.Env = append(cmd.Env, entry) + } +} + +// runRaw preserves path whitespace, NUL delimiters, and output on nonzero exits. +func (d *defaultOps) runRaw(args ...string) (string, error) { + cmd, err := d.command(args...) if err != nil { return "", err } - return strings.TrimSpace(string(out)), nil + out, err := cmd.Output() + return string(out), err +} + +func (d *defaultOps) run(args ...string) (string, error) { + out, err := d.runRaw(args...) + return strings.TrimSpace(out), err } // runSilent executes a git command via the client and only returns an error. -func runSilent(args ...string) error { - cmd, err := client.Command(context.Background(), args...) +func (d *defaultOps) runSilent(args ...string) error { + cmd, err := d.command(args...) if err != nil { return err } @@ -57,8 +93,11 @@ func runSilent(args ...string) error { // runInteractive runs a git command with stdin/stdout/stderr connected to // the terminal, allowing interactive programs like editors to work. -func runInteractive(args ...string) error { - cmd := exec.Command("git", args...) +func (d *defaultOps) runInteractive(args ...string) error { + cmd, err := d.command(args...) + if err != nil { + return err + } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -84,30 +123,53 @@ func IsRebaseStartError(err error) bool { return errors.As(err, &startErr) } -func runRebaseCommand(args []string, opts RebaseOpts) error { - if IsRebaseInProgress() { +func (d *defaultOps) runRebaseCommand(args []string, opts RebaseOpts) error { + inProgress, err := d.rebaseInProgress() + if err != nil { + return &RebaseStartError{Err: err} + } + if inProgress { return &RebaseStartError{Err: errors.New("a rebase is already in progress")} } - err := runSilent(args...) + err = d.runSilent(args...) if err == nil { return nil } - err = tryAutoResolveRebase(err, opts) - if err != nil && !IsRebaseInProgress() { + err = d.tryAutoResolveRebase(err, opts) + if err == nil { + return nil + } + inProgress, stateErr := d.rebaseInProgress() + if stateErr != nil { + return errors.Join(err, stateErr) + } + if !inProgress { return &RebaseStartError{Err: err} } return err } -// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. -func rebaseContinueOnce(opts RebaseOpts) error { - args := []string{"rebase"} +func rebaseArgs(opts RebaseOpts) []string { + // The cascade owns its ref range and must never stash another worktree. + // Use configuration overrides rather than flags unavailable in Git 2.36. + args := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "rebase"} if opts.CommitterDateIsAuthorDate { - args = append(args, "--committer-date-is-author-date") + // The apply backend loses this option after a conflict. The merge + // backend persists it for continuation and rerere auto-continuation. + args = append(args, "--merge", "--committer-date-is-author-date") } - args = append(args, "--continue") - cmd := exec.Command("git", args...) - cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + return args +} + +// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. +func (d *defaultOps) rebaseContinueOnce(_ RebaseOpts) error { + // The merge backend persists date options at rebase start. Repeating + // start-only options with --continue does not change that saved state. + cmd, err := d.command(append(rebaseArgs(RebaseOpts{}), "--continue")...) + if err != nil { + return err + } + cmd.Env = append(cmd.Environ(), "GIT_EDITOR=true") return cmd.Run() } @@ -115,24 +177,36 @@ func rebaseContinueOnce(opts RebaseOpts) error { // from a failed rebase. If so, it auto-continues the rebase (potentially // multiple times for multi-commit rebases). Returns originalErr if any // conflicts remain that need manual resolution. -func tryAutoResolveRebase(originalErr error, opts RebaseOpts) error { +func (d *defaultOps) tryAutoResolveRebase(originalErr error, opts RebaseOpts) error { + previousStep := "" for i := 0; i < 1000; i++ { - if !IsRebaseInProgress() { - if i == 0 { - return originalErr - } - return nil - } - conflicts, err := ConflictedFiles() + inProgress, err := d.rebaseInProgress() if err != nil { + return errors.Join(originalErr, err) + } + if !inProgress { return originalErr } + conflicts, err := d.ConflictedFiles() + if err != nil { + return errors.Join(originalErr, err) + } if len(conflicts) > 0 { return originalErr } + step, err := d.rebaseStep() + if err != nil { + return errors.Join(originalErr, err) + } + if step == previousStep { + return originalErr + } + previousStep = step // Rerere resolved all conflicts — auto-continue. - if rebaseContinueOnce(opts) == nil { + if err := d.rebaseContinueOnce(opts); err == nil { return nil + } else { + originalErr = err } // Continue hit another conflicting commit; loop to check // if rerere resolved that one too. @@ -140,13 +214,52 @@ func tryAutoResolveRebase(originalErr error, opts RebaseOpts) error { return originalErr } +func (d *defaultOps) rebaseStep() (string, error) { + gitDir, err := d.GitDir() + if err != nil { + return "", err + } + for _, file := range []string{"rebase-merge/msgnum", "rebase-apply/next"} { + data, err := os.ReadFile(filepath.Join(gitDir, filepath.FromSlash(file))) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", err + } + return file + ":" + string(data), nil + } + return "", fmt.Errorf("cannot find rebase progress in %q", gitDir) +} + // --- Public functions delegate through the ops interface --- -// GitDir returns the path to the .git directory. +// GitDir returns the absolute path to the current worktree's Git directory. func GitDir() (string, error) { return ops.GitDir() } +// CommonDir returns the absolute Git directory shared by all linked worktrees. +func CommonDir() (string, error) { + return ops.CommonDir() +} + +// Worktrees lists all registered worktrees, including the main worktree. +func Worktrees() ([]Worktree, error) { + return ops.Worktrees() +} + +// ForWorktree returns operations scoped to a worktree in the same repository. +// Invalid contexts return errors from the resulting operations. +func ForWorktree(path string) Ops { + return ops.ForWorktree(path) +} + +// CheckVersion requires Git 2.36 or newer. +func CheckVersion() error { + return ops.CheckVersion() +} + // RootDir returns the repository's root directory. func RootDir() (string, error) { return ops.RootDir() diff --git a/internal/git/gitops.go b/internal/git/gitops.go index 2464f4ec..c02c38fb 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + + cligit "github.com/cli/cli/v2/git" ) // RebaseOpts holds optional parameters for git rebase operations. @@ -27,6 +29,10 @@ var ErrRemoteBranchNotFound = errors.New("remote branch not found") // Tests can substitute a mock via SetOps(). type Ops interface { GitDir() (string, error) + CommonDir() (string, error) + Worktrees() ([]Worktree, error) + ForWorktree(path string) Ops + CheckVersion() error RootDir() (string, error) CurrentBranch() (string, error) BranchExists(name string) bool @@ -86,7 +92,13 @@ type Ops interface { } // defaultOps implements Ops by delegating to the real git client and helpers. -type defaultOps struct{} +type defaultOps struct { + client *cligit.Client + scoped bool + scopeErr error + gitDir os.FileInfo + commonDir os.FileInfo +} var _ Ops = (*defaultOps)(nil) @@ -108,32 +120,50 @@ func CurrentOps() Ops { // --- defaultOps method implementations --- func (d *defaultOps) GitDir() (string, error) { - return client.GitDir(context.Background()) + return d.path("rev-parse", "--absolute-git-dir") } func (d *defaultOps) RootDir() (string, error) { - return run("rev-parse", "--show-toplevel") + return d.path("rev-parse", "--show-toplevel") } func (d *defaultOps) CurrentBranch() (string, error) { - return client.CurrentBranch(context.Background()) + branch, err := d.run("symbolic-ref", "--quiet", "HEAD") + if err != nil { + var gitErr *cligit.GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 && gitErr.Stderr == "" { + return "", cligit.ErrNotOnAnyBranch + } + return "", err + } + return strings.TrimPrefix(branch, "refs/heads/"), nil } func (d *defaultOps) BranchExists(name string) bool { - return client.HasLocalBranch(context.Background(), name) + _, err := d.run("rev-parse", "--verify", "refs/heads/"+name) + return err == nil } func (d *defaultOps) CheckoutBranch(name string) error { - return client.CheckoutBranch(context.Background(), name) + return d.runSilent("checkout", name) } func (d *defaultOps) Fetch(remote string) error { - return client.Fetch(context.Background(), remote, "") + c, err := d.gitClient() + if err != nil { + return err + } + cmd, err := c.AuthenticatedCommand(context.Background(), cligit.AllMatchingCredentialsPattern, "fetch", remote) + if err != nil { + return err + } + d.configureCommand(cmd) + return cmd.Run() } func (d *defaultOps) FetchBranch(remote, branch string) error { refspec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branch, remote, branch) - if err := runSilent("fetch", remote, refspec); err != nil { + if err := d.runSilent("fetch", remote, refspec); err != nil { if isMissingRemoteRefError(err) { return fmt.Errorf("%w: %s/%s", ErrRemoteBranchNotFound, remote, branch) } @@ -156,7 +186,7 @@ func (d *defaultOps) FetchBranches(remote string, branches []string) error { // Fast path: fetch all branches in a single call. args := []string{"fetch", remote} args = append(args, refspecs...) - if err := runSilent(args...); err == nil { + if err := d.runSilent(args...); err == nil { return nil } // Fallback: one branch may be absent on the remote or deleted since @@ -164,7 +194,7 @@ func (d *defaultOps) FetchBranches(remote string, branches []string) error { // block the rest, while still surfacing real fetch failures. var fetchErr error for _, rs := range refspecs { - err := runSilent("fetch", remote, rs) + err := d.runSilent("fetch", remote, rs) if err == nil || isMissingRemoteRefError(err) { continue } @@ -180,10 +210,10 @@ func isMissingRemoteRefError(err error) bool { } func (d *defaultOps) DefaultBranch() (string, error) { - ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") + ref, err := d.run("symbolic-ref", "refs/remotes/origin/HEAD") if err != nil { for _, name := range []string{"main", "master"} { - if BranchExists(name) { + if d.BranchExists(name) { return name, nil } } @@ -193,7 +223,7 @@ func (d *defaultOps) DefaultBranch() (string, error) { } func (d *defaultOps) CreateBranch(name, base string) error { - return runSilent("branch", name, base) + return d.runSilent("branch", name, base) } func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) error { @@ -205,7 +235,7 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) // was missing before the preceding FetchBranches call. for _, b := range branches { trackingRef := fmt.Sprintf("refs/remotes/%s/%s", remote, b) - sha, err := run("rev-parse", "--verify", "--quiet", trackingRef) + sha, err := d.run("rev-parse", "--verify", "--quiet", trackingRef) if err == nil && sha != "" { // Tracking ref exists: lease against the known SHA. args = append(args, fmt.Sprintf("--force-with-lease=refs/heads/%s:%s", b, sha)) @@ -228,7 +258,7 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) for _, b := range branches { args = append(args, fmt.Sprintf("refs/heads/%s:refs/heads/%s", b, b)) } - return runSilent(args...) + return d.runSilent(args...) } // ResolveRemote determines the remote for pushing a branch. It checks git @@ -244,7 +274,7 @@ func (d *defaultOps) ResolveRemote(branch string) (string, error) { "branch." + branch + ".remote", } for _, key := range candidates { - out, err := run("config", "--get", key) + out, err := d.run("config", "--get", key) if err == nil && out != "" { return out, nil } @@ -255,7 +285,7 @@ func (d *defaultOps) ResolveRemote(branch string) (string, error) { return saved, nil } - out, err := run("remote") + out, err := d.run("remote") if err != nil { return "", fmt.Errorf("could not list remotes: %w", err) } @@ -270,44 +300,48 @@ func (d *defaultOps) ResolveRemote(branch string) (string, error) { } func (d *defaultOps) Rebase(base string, opts RebaseOpts) error { - args := []string{"rebase"} - if opts.CommitterDateIsAuthorDate { - args = append(args, "--committer-date-is-author-date") - } + args := rebaseArgs(opts) args = append(args, base) - return runRebaseCommand(args, opts) + return d.runRebaseCommand(args, opts) } func (d *defaultOps) EnableRerere() error { - if err := runSilent("config", "rerere.enabled", "true"); err != nil { + if err := d.runSilent("config", "rerere.enabled", "true"); err != nil { return err } - return runSilent("config", "rerere.autoupdate", "true") + return d.runSilent("config", "rerere.autoupdate", "true") } func (d *defaultOps) IsRerereEnabled() (bool, error) { - out, err := run("config", "--get", "rerere.enabled") + out, err := d.run("config", "--get", "rerere.enabled") if err != nil { - // Missing key — not enabled. - return false, nil + var gitErr *cligit.GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 { + return false, nil + } + return false, err } return strings.EqualFold(strings.TrimSpace(out), "true"), nil } func (d *defaultOps) IsRerereDeclined() (bool, error) { - out, err := run("config", "--get", "gh-stack.rerere-declined") + out, err := d.run("config", "--get", "gh-stack.rerere-declined") if err != nil { - return false, nil + var gitErr *cligit.GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 { + return false, nil + } + return false, err } return strings.EqualFold(strings.TrimSpace(out), "true"), nil } func (d *defaultOps) SaveRerereDeclined() error { - return runSilent("config", "gh-stack.rerere-declined", "true") + return d.runSilent("config", "gh-stack.rerere-declined", "true") } func (d *defaultOps) GetSavedRemote() (string, error) { - out, err := run("config", "--get", "gh-stack.remote") + out, err := d.run("config", "--get", "gh-stack.remote") if err != nil { return "", err } @@ -315,88 +349,130 @@ func (d *defaultOps) GetSavedRemote() (string, error) { } func (d *defaultOps) SaveRemote(remote string) error { - return runSilent("config", "gh-stack.remote", remote) + return d.runSilent("config", "gh-stack.remote", remote) } func (d *defaultOps) ClearRemote() error { - return runSilent("config", "--unset", "gh-stack.remote") + return d.runSilent("config", "--unset", "gh-stack.remote") } func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error { - args := []string{"rebase"} - if opts.CommitterDateIsAuthorDate { - args = append(args, "--committer-date-is-author-date") - } + args := rebaseArgs(opts) args = append(args, "--onto", newBase, oldBase, branch) - return runRebaseCommand(args, opts) + return d.runRebaseCommand(args, opts) } func (d *defaultOps) RebaseContinue(opts RebaseOpts) error { - err := rebaseContinueOnce(opts) + err := d.rebaseContinueOnce(opts) if err == nil { return nil } - return tryAutoResolveRebase(err, opts) + return d.tryAutoResolveRebase(err, opts) } func (d *defaultOps) RebaseAbort() error { - return runSilent("rebase", "--abort") + return d.runSilent(append(rebaseArgs(RebaseOpts{}), "--abort")...) } func (d *defaultOps) IsRebaseInProgress() bool { - gitDir, err := GitDir() + inProgress, _ := d.rebaseInProgress() + return inProgress +} + +func (d *defaultOps) rebaseInProgress() (bool, error) { + gitDir, err := d.GitDir() if err != nil { - return false + return false, err } for _, dir := range []string{"rebase-merge", "rebase-apply"} { rebasePath := filepath.Join(gitDir, dir) - if info, err := os.Stat(rebasePath); err == nil && info.IsDir() { - return true + info, err := os.Stat(rebasePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("checking rebase state in %q: %w", gitDir, err) + } + if err == nil && info.IsDir() { + return true, nil } } - return false + return false, nil } func (d *defaultOps) ConflictedFiles() ([]string, error) { - output, err := run("diff", "--name-only", "--diff-filter=U") + output, err := d.runRaw("diff", "--no-relative", "--name-only", "--diff-filter=U", "-z") if err != nil { return nil, err } if output == "" { return nil, nil } - return strings.Split(output, "\n"), nil + return strings.Split(strings.TrimSuffix(output, "\x00"), "\x00"), nil } func (d *defaultOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { - output, err := run("diff", "--check", "--", filePath) - if output == "" && err != nil { + root, err := d.RootDir() + if err != nil { + return nil, err + } + fullPath := filePath + if !filepath.IsAbs(fullPath) { + fullPath = filepath.Join(root, fullPath) + } + fullPath, err = filepath.EvalSymlinks(fullPath) + if err != nil { + return nil, fmt.Errorf("resolving conflict file %q: %w", filePath, err) + } + relativePath, err := filepath.Rel(root, fullPath) + if err != nil { + return nil, err + } + if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("conflict file %q is outside worktree %q", filePath, root) + } + cmd, err := d.command("diff", "--no-relative", "--check", "--", ":(top,literal)"+filepath.ToSlash(relativePath)) + if err != nil { + return nil, err + } + cmd.Env = append(cmd.Environ(), "LC_ALL=C") + output, err := cmd.Output() + var exitErr *exec.ExitError + if err != nil && (!errors.As(err, &exitErr) || exitErr.ExitCode() != 2) { return nil, err } info := &ConflictMarkerInfo{File: filePath} - var currentSection *ConflictSection - - for _, line := range strings.Split(output, "\n") { - line = strings.TrimSpace(line) - if line == "" { + var lines []string + for _, line := range strings.Split(string(output), "\n") { + marker := strings.LastIndex(line, ": leftover conflict marker") + if marker < 0 { continue } - parts := strings.SplitN(line, ":", 3) - if len(parts) < 3 { + // Parse from the right: filenames may contain colons or newlines. + prefix := line[:marker] + colon := strings.LastIndexByte(prefix, ':') + if colon < 0 { continue } - lineNo, parseErr := strconv.Atoi(strings.TrimSpace(parts[1])) - if parseErr != nil { - continue + lineNo, err := strconv.Atoi(prefix[colon+1:]) + if err != nil { + return nil, fmt.Errorf("invalid conflict marker location %q: %w", line, err) } - marker := strings.TrimSpace(parts[2]) - if strings.Contains(marker, "leftover conflict marker") { - if currentSection == nil || currentSection.EndLine != 0 { - currentSection = &ConflictSection{StartLine: lineNo} - info.Sections = append(info.Sections, *currentSection) + if lines == nil { + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, err + } + lines = strings.Split(string(content), "\n") + } + if lineNo < 1 || lineNo > len(lines) || lines[lineNo-1] == "" { + return nil, fmt.Errorf("conflict file %q changed while reading markers", filePath) + } + switch lines[lineNo-1][0] { + case '<': + info.Sections = append(info.Sections, ConflictSection{StartLine: lineNo}) + case '>': + if len(info.Sections) > 0 { + info.Sections[len(info.Sections)-1].EndLine = lineNo } - info.Sections[len(info.Sections)-1].EndLine = lineNo } } @@ -404,7 +480,7 @@ func (d *defaultOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, } func (d *defaultOps) IsAncestor(ancestor, descendant string) (bool, error) { - err := runSilent("merge-base", "--is-ancestor", ancestor, descendant) + err := d.runSilent("merge-base", "--is-ancestor", ancestor, descendant) if err == nil { return true, nil } @@ -416,7 +492,7 @@ func (d *defaultOps) IsAncestor(ancestor, descendant string) (bool, error) { } func (d *defaultOps) RevParse(ref string) (string, error) { - return run("rev-parse", ref) + return d.run("rev-parse", ref) } func (d *defaultOps) RevParseMulti(refs []string) ([]string, error) { @@ -424,7 +500,7 @@ func (d *defaultOps) RevParseMulti(refs []string) ([]string, error) { return nil, nil } args := append([]string{"rev-parse"}, refs...) - out, err := run(args...) + out, err := d.run(args...) if err != nil { return nil, err } @@ -436,16 +512,16 @@ func (d *defaultOps) RevParseMulti(refs []string) ([]string, error) { } func (d *defaultOps) MergeBase(a, b string) (string, error) { - return run("merge-base", a, b) + return d.run("merge-base", a, b) } func (d *defaultOps) MergeBaseForkPoint(ref, branch string) (string, error) { - return run("merge-base", "--fork-point", ref, branch) + return d.run("merge-base", "--fork-point", ref, branch) } func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { format := "%H\t%s\t%at" - output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) + output, err := d.run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) if err != nil { return nil, err } @@ -472,7 +548,7 @@ func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { func (d *defaultOps) LogRange(base, head string) ([]CommitInfo, error) { format := "%H%x01%B%x01%at%x00" rangeSpec := base + ".." + head - output, err := run("log", rangeSpec, "--format="+format) + output, err := d.run("log", rangeSpec, "--format="+format) if err != nil { return nil, err } @@ -516,7 +592,7 @@ func splitCommitMessage(msg string) (subject, body string) { } func (d *defaultOps) DiffStatRange(base, head string) (additions, deletions int, err error) { - output, err := run("diff", "--numstat", base+".."+head) + output, err := d.run("diff", "--numstat", base+".."+head) if err != nil { return 0, 0, err } @@ -540,7 +616,7 @@ func (d *defaultOps) DiffStatRange(base, head string) (additions, deletions int, } func (d *defaultOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) { - output, err := run("diff", "--numstat", base+".."+head) + output, err := d.run("diff", "--numstat", base+".."+head) if err != nil { return nil, err } @@ -569,86 +645,86 @@ func (d *defaultOps) DeleteBranch(name string, force bool) error { if force { flag = "-D" } - return runSilent("branch", flag, name) + return d.runSilent("branch", flag, name) } func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { // Fully-qualify the ref so a branch name is never reinterpreted as // refspec syntax. - return runSilent("push", remote, "--delete", "refs/heads/"+branch) + return d.runSilent("push", remote, "--delete", "refs/heads/"+branch) } func (d *defaultOps) DeleteTrackingRef(remote, branch string) error { - return runSilent("branch", "-dr", remote+"/"+branch) + return d.runSilent("branch", "-dr", remote+"/"+branch) } func (d *defaultOps) ResetHard(ref string) error { - return runSilent("reset", "--hard", ref) + return d.runSilent("reset", "--hard", ref) } func (d *defaultOps) SetUpstreamTracking(branch, remote string) error { - return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) + return d.runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) } func (d *defaultOps) UpstreamRemote(branch string) (string, error) { - return run("config", "--get", "branch."+branch+".remote") + return d.run("config", "--get", "branch."+branch+".remote") } func (d *defaultOps) MergeFF(target string) error { - return runSilent("merge", "--ff-only", target) + return d.runSilent("-c", "merge.autoStash=false", "merge", "--ff-only", target) } func (d *defaultOps) UpdateBranchRef(branch, sha string) error { - return runSilent("branch", "-f", branch, sha) + return d.runSilent("branch", "-f", branch, sha) } func (d *defaultOps) StageAll() error { - return runSilent("add", "-A") + return d.runSilent("add", "-A") } func (d *defaultOps) StageTracked() error { - return runSilent("add", "-u") + return d.runSilent("add", "-u") } func (d *defaultOps) HasStagedChanges() bool { - err := runSilent("diff", "--cached", "--quiet") + err := d.runSilent("diff", "--cached", "--quiet") return err != nil } func (d *defaultOps) Commit(message string) (string, error) { - if err := runSilent("commit", "-m", message); err != nil { + if err := d.runSilent("commit", "-m", message); err != nil { return "", err } - return run("rev-parse", "HEAD") + return d.run("rev-parse", "HEAD") } // CommitInteractive launches the user's editor for the commit message. func (d *defaultOps) CommitInteractive() (string, error) { - if err := runInteractive("commit"); err != nil { + if err := d.runInteractive("commit"); err != nil { return "", err } - return run("rev-parse", "HEAD") + return d.run("rev-parse", "HEAD") } func (d *defaultOps) ValidateRefName(name string) error { - _, err := run("check-ref-format", "--branch", name) + _, err := d.run("check-ref-format", "--branch", name) return err } func (d *defaultOps) RenameBranch(oldName, newName string) error { - return runSilent("branch", "-m", oldName, newName) + return d.runSilent("branch", "-m", oldName, newName) } func (d *defaultOps) CherryPick(commits []string) error { args := append([]string{"cherry-pick"}, commits...) - return runSilent(args...) + return d.runSilent(args...) } // CherryPickQuit clears the in-progress cherry-pick sequencer state without // touching the working tree or index (git cherry-pick --quit). Used to clear // any stale sequencer state before starting a fresh cherry-pick. func (d *defaultOps) CherryPickQuit() error { - return runSilent("cherry-pick", "--quit") + return d.runSilent("cherry-pick", "--quit") } // CherryPickAbort cancels an in-progress cherry-pick and restores the working @@ -656,30 +732,44 @@ func (d *defaultOps) CherryPickQuit() error { // (git cherry-pick --abort). Errors if no cherry-pick is in progress, so // callers should gate this with IsCherryPickInProgress. func (d *defaultOps) CherryPickAbort() error { - return runSilent("cherry-pick", "--abort") + return d.runSilent("cherry-pick", "--abort") } func (d *defaultOps) CherryPickContinue() error { - cmd := exec.Command("git", "cherry-pick", "--continue") - cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + cmd, err := d.command("cherry-pick", "--continue") + if err != nil { + return err + } + cmd.Env = append(cmd.Environ(), "GIT_EDITOR=true") return cmd.Run() } // IsCherryPickInProgress reports whether a cherry-pick is currently in progress -// by checking for the CHERRY_PICK_HEAD marker in the git directory. +// by checking its native marker and any remaining sequencer picks. func (d *defaultOps) IsCherryPickInProgress() bool { - gitDir, err := GitDir() + gitDir, err := d.GitDir() if err != nil { return false } if _, err := os.Stat(filepath.Join(gitDir, "CHERRY_PICK_HEAD")); err == nil { return true } + // A manual commit can clear CHERRY_PICK_HEAD while a multi-commit + // cherry-pick still has pending work. + todo, err := os.ReadFile(filepath.Join(gitDir, "sequencer", "todo")) + if err != nil { + return false + } + for _, line := range strings.Split(string(todo), "\n") { + if strings.HasPrefix(line, "pick ") { + return true + } + } return false } func (d *defaultOps) HasUncommittedChanges() (bool, error) { - out, err := run("status", "--porcelain") + out, err := d.run("status", "--porcelain", "--untracked-files=all") if err != nil { return false, err } @@ -689,7 +779,7 @@ func (d *defaultOps) HasUncommittedChanges() (bool, error) { func (d *defaultOps) LogMerges(base, head string) ([]CommitInfo, error) { format := "%H%x01%B%x01%at%x00" rangeSpec := base + ".." + head - output, err := run("log", "--merges", rangeSpec, "--format="+format) + output, err := d.run("log", "--merges", rangeSpec, "--format="+format) if err != nil { return nil, err } diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go index 3d5a5f3d..e79fa762 100644 --- a/internal/git/gitops_test.go +++ b/internal/git/gitops_test.go @@ -1,12 +1,15 @@ package git import ( + "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" + cligit "github.com/cli/cli/v2/git" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -667,3 +670,818 @@ func TestIntegration_CherryPickQuitLeavesIndexUnmerged(t *testing.T) { _, coErr := gitExecMayFail(t, cloneDir, "checkout", "feature") require.Error(t, coErr, "checkout should still fail after --quit because the index is unmerged") } + +// --------------------------------------------------------------------------- +// Real linked-worktree integration tests +// --------------------------------------------------------------------------- + +func setupWorktreeRepo(t *testing.T) (*defaultOps, string) { + t.Helper() + _, dir := setupBareAndClone(t) + gitExec(t, dir, "config", "user.name", "Test") + gitExec(t, dir, "config", "user.email", "test@test.com") + gitExec(t, dir, "config", "commit.gpgsign", "false") + gitExec(t, dir, "config", "rerere.enabled", "false") + gitExec(t, dir, "config", "merge.conflictStyle", "merge") + gitExec(t, dir, "config", "rebase.backend", "merge") + t.Cleanup(withGitDir(t, dir)) + return &defaultOps{client: &cligit.Client{RepoDir: dir}}, dir +} + +func canonicalGitTestPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + require.NoError(t, err) + return filepath.ToSlash(resolved) +} + +func addTestWorktree(t *testing.T, root *defaultOps, dir, branch string) (Ops, string) { + t.Helper() + path := filepath.Join(t.TempDir(), branch+" worktree") + gitExec(t, dir, "worktree", "add", "-b", branch, path, "main") + scoped := root.ForWorktree(path) + got, err := scoped.CurrentBranch() + require.NoError(t, err) + require.Equal(t, branch, got) + return scoped, path +} + +func TestIntegration_WorktreeDirectoriesAndDiscovery(t *testing.T) { + root, dir := setupWorktreeRepo(t) + beforeWD, err := os.Getwd() + require.NoError(t, err) + beforeClientDir := client.RepoDir + beforeOps := CurrentOps() + + linked, linkedPath := addTestWorktree(t, root, dir, "feature-\u03bb") + gitExec(t, dir, "worktree", "lock", "--reason", "a reason\nwith newlines", linkedPath) + detachedPath := filepath.Join(t.TempDir(), "detached") + gitExec(t, dir, "worktree", "add", "--detach", detachedPath, "main") + missingPath := filepath.Join(t.TempDir(), "missing") + gitExec(t, dir, "worktree", "add", "-b", "missing", missingPath, "main") + canonicalMissing := canonicalGitTestPath(t, missingPath) + require.NoError(t, os.Rename(missingPath, missingPath+"-moved")) + + common, err := root.CommonDir() + require.NoError(t, err) + mainGitDir, err := root.GitDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, filepath.Join(dir, ".git")), common) + assert.Equal(t, common, mainGitDir) + linkedCommon, err := linked.CommonDir() + require.NoError(t, err) + linkedGitDir, err := linked.GitDir() + require.NoError(t, err) + assert.Equal(t, common, linkedCommon) + assert.NotEqual(t, common, linkedGitDir) + assert.True(t, filepath.IsAbs(linkedGitDir)) + assert.True(t, strings.HasPrefix(filepath.Clean(linkedGitDir), filepath.Join(common, "worktrees")+string(filepath.Separator))) + + subdir := filepath.Join(linkedPath, "sub", "directory") + require.NoError(t, os.MkdirAll(subdir, 0755)) + sub := linked.ForWorktree(filepath.Join("sub", "directory")) + subRoot, err := sub.RootDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, linkedPath), subRoot) + subGitDir, err := sub.GitDir() + require.NoError(t, err) + assert.Equal(t, linkedGitDir, subGitDir) + subCommon, err := sub.CommonDir() + require.NoError(t, err) + assert.Equal(t, common, subCommon) + + worktrees, err := sub.Worktrees() + require.NoError(t, err) + require.Len(t, worktrees, 4) + assert.Equal(t, canonicalGitTestPath(t, dir), worktrees[0].Path, "the main owner must not be omitted") + assert.ElementsMatch(t, []Worktree{ + {Path: canonicalGitTestPath(t, dir), Branch: "main"}, + {Path: canonicalGitTestPath(t, linkedPath), Branch: "feature-\u03bb", Locked: true}, + {Path: canonicalGitTestPath(t, detachedPath), Detached: true}, + {Path: canonicalMissing, Branch: "missing", Prunable: true}, + }, worktrees) + + main := linked.ForWorktree(dir) + branch, err := main.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "main", branch) + afterWD, err := os.Getwd() + require.NoError(t, err) + assert.Equal(t, beforeWD, afterWD) + assert.Equal(t, beforeClientDir, client.RepoDir) + assert.Same(t, beforeOps, CurrentOps()) +} + +func TestIntegration_WorktreePathsPreserveWhitespace(t *testing.T) { + for _, name := range []string{"space and \u03bb", " leading and trailing ", "embedded\nand trailing\n", `quotes " and backslash \`} { + t.Run(name, func(t *testing.T) { + if runtime.GOOS == "windows" && (strings.ContainsAny(name, "\n\"\\") || strings.HasSuffix(name, " ")) { + t.Skip("these filename characters are not supported on Windows") + } + root, dir := setupWorktreeRepo(t) + mainPath := filepath.Join(filepath.Dir(dir), "main "+name) + require.NoError(t, os.Rename(dir, mainPath)) + root.client.RepoDir = mainPath + linkedPath := filepath.Join(t.TempDir(), name) + gitExec(t, mainPath, "worktree", "add", "-b", "feature", linkedPath, "main") + linked := root.ForWorktree(linkedPath) + gotRoot, err := linked.RootDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, linkedPath), gotRoot) + common, err := linked.CommonDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, filepath.Join(mainPath, ".git")), common) + worktrees, err := linked.Worktrees() + require.NoError(t, err) + assert.ElementsMatch(t, []Worktree{ + {Path: canonicalGitTestPath(t, mainPath), Branch: "main"}, + {Path: canonicalGitTestPath(t, linkedPath), Branch: "feature"}, + }, worktrees) + }) + } +} + +func TestIntegration_BareHostedWorktree(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + bare, _ := setupBareAndClone(t) + linkedPath := filepath.Join(t.TempDir(), "linked") + gitExec(t, bare, "-c", "safe.bareRepository=all", "worktree", "add", "-b", "feature", linkedPath, "main") + t.Cleanup(withGitDir(t, linkedPath)) + root := &defaultOps{client: &cligit.Client{RepoDir: linkedPath}} + linked := root.ForWorktree(linkedPath) + common, err := linked.CommonDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, bare), common) + gitDir, err := linked.GitDir() + require.NoError(t, err) + assert.NotEqual(t, common, gitDir) + worktrees, err := linked.Worktrees() + require.NoError(t, err) + assert.ElementsMatch(t, []Worktree{ + {Path: canonicalGitTestPath(t, bare), Bare: true}, + {Path: canonicalGitTestPath(t, linkedPath), Branch: "feature"}, + }, worktrees) +} + +func setupSeparateGitRepo(t *testing.T) (*defaultOps, string, string) { + t.Helper() + dir := filepath.Join(t.TempDir(), "main") + gitDir := filepath.Join(t.TempDir(), "separate git directory") + gitExec(t, ".", "init", "-b", "main", "--separate-git-dir", gitDir, dir) + writeFile(t, dir, "init.txt", "initial") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "initial") + t.Cleanup(withGitDir(t, dir)) + root := &defaultOps{client: &cligit.Client{RepoDir: dir}} + return root, dir, gitDir +} + +func TestIntegration_SeparateGitDirectory(t *testing.T) { + root, dir, gitDir := setupSeparateGitRepo(t) + linked, linkedPath := addTestWorktree(t, root, dir, "feature") + for _, scope := range []Ops{root, linked} { + common, err := scope.CommonDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, gitDir), common) + } + mainGitDir, err := root.GitDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, gitDir), mainGitDir) + linkedRoot, err := linked.RootDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, linkedPath), linkedRoot) + linkedGitDir, err := linked.GitDir() + require.NoError(t, err) + assert.NotEqual(t, mainGitDir, linkedGitDir) + subdir := filepath.Join(dir, "subdirectory") + require.NoError(t, os.MkdirAll(subdir, 0755)) + for _, scope := range []Ops{root, root.ForWorktree(dir), root.ForWorktree(subdir)} { + worktrees, err := scope.Worktrees() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, dir), worktrees[0].Path) + assert.Equal(t, "main", worktrees[0].Branch) + } + main := linked.ForWorktree(dir) + mainRoot, err := main.RootDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, dir), mainRoot) + mainBranch, err := main.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "main", mainBranch) +} + +func TestIntegration_SeparateGitDirectoryBacklink(t *testing.T) { + tests := []struct { + name string + relative bool + worktreeConfig bool + newlines bool + }{ + {name: "common absolute"}, + {name: "common relative", relative: true}, + {name: "main config.worktree", worktreeConfig: true}, + {name: "relative main config.worktree", relative: true, worktreeConfig: true}, + {name: "newline path", worktreeConfig: true, newlines: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.newlines && runtime.GOOS == "windows" { + t.Skip("Windows does not support newlines in filenames") + } + root, dir, gitDir := setupSeparateGitRepo(t) + if tt.newlines { + newPath := dir + " with \u03bb\nand trailing\n" + require.NoError(t, os.Rename(dir, newPath)) + dir = newPath + root.client.RepoDir = dir + } + linked, linkedPath := addTestWorktree(t, root, dir, "feature") + backlink := canonicalGitTestPath(t, dir) + if tt.relative { + var err error + backlink, err = filepath.Rel(canonicalGitTestPath(t, gitDir), backlink) + require.NoError(t, err) + } + if tt.worktreeConfig { + gitExec(t, dir, "config", "extensions.worktreeConfig", "true") + gitExec(t, dir, "config", "--worktree", "core.worktree", backlink) + gitExec(t, linkedPath, "config", "--worktree", "core.worktree", canonicalGitTestPath(t, linkedPath)) + } else { + gitExec(t, dir, "config", "core.worktree", backlink) + } + worktrees, err := linked.Worktrees() + require.NoError(t, err) + require.Len(t, worktrees, 2) + assert.Equal(t, canonicalGitTestPath(t, dir), worktrees[0].Path) + assert.Equal(t, "main", worktrees[0].Branch) + main := linked.ForWorktree(worktrees[0].Path) + mainRoot, err := main.RootDir() + require.NoError(t, err) + assert.Equal(t, canonicalGitTestPath(t, dir), mainRoot) + mainBranch, err := main.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "main", mainBranch) + linkedBranch, err := linked.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "feature", linkedBranch) + }) + } +} + +func TestIntegration_SeparateGitDirectoryMissingBacklink(t *testing.T) { + root, dir, gitDir := setupSeparateGitRepo(t) + linked, linkedPath := addTestWorktree(t, root, dir, "feature") + worktrees, err := linked.Worktrees() + require.NoError(t, err, "an unknown main path must not block unrelated worktrees") + require.Len(t, worktrees, 2) + assert.Equal(t, canonicalGitTestPath(t, gitDir), worktrees[0].Path) + assert.Equal(t, "main", worktrees[0].Branch) + + require.NoError(t, linked.CreateBranch("independent", "feature")) + require.NoError(t, linked.CheckoutBranch("independent")) + assert.Equal(t, "independent", gitExec(t, linkedPath, "branch", "--show-current")) + main := linked.ForWorktree(worktrees[0].Path) + _, err = main.HasUncommittedChanges() + require.ErrorContains(t, err, "main worktree") + assert.Contains(t, err.Error(), "core.worktree backlink") + require.Error(t, main.StageAll()) + assert.Equal(t, "main", gitExec(t, dir, "branch", "--show-current")) + + // An explicitly supplied real path is still usable without a backlink. + explicit := linked.ForWorktree(dir) + current, err := explicit.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "main", current) +} + +func TestIntegration_SeparateGitDirectoryUnavailableBacklink(t *testing.T) { + for _, foreign := range []bool{false, true} { + t.Run(fmt.Sprintf("foreign=%t", foreign), func(t *testing.T) { + root, dir, _ := setupSeparateGitRepo(t) + linked, _ := addTestWorktree(t, root, dir, "feature") + backlink := filepath.Join(t.TempDir(), "missing main worktree") + if foreign { + _, backlink = setupBareAndClone(t) + } + gitExec(t, dir, "config", "extensions.worktreeConfig", "true") + gitExec(t, dir, "config", "--worktree", "core.worktree", backlink) + worktrees, err := linked.Worktrees() + require.NoError(t, err, "an unavailable main must not block unrelated worktrees") + assert.Equal(t, filepath.ToSlash(filepath.Clean(backlink)), worktrees[0].Path) + _, err = linked.ForWorktree(worktrees[0].Path).HasUncommittedChanges() + require.Error(t, err) + dirty, err := linked.HasUncommittedChanges() + require.NoError(t, err) + assert.False(t, dirty) + }) + } +} + +func TestIntegration_ForWorktreeRejectsInvalidContext(t *testing.T) { + root, dir := setupWorktreeRepo(t) + _, other := setupBareAndClone(t) + for _, path := range []string{"", filepath.Join(t.TempDir(), "missing"), other} { + t.Run(path, func(t *testing.T) { + selected := root.ForWorktree(path) + _, err := selected.CommonDir() + require.Error(t, err) + _, err = selected.HasUncommittedChanges() + require.Error(t, err) + _, err = selected.IsRerereEnabled() + require.Error(t, err) + require.Error(t, selected.StageAll()) + require.Error(t, selected.ResetHard("HEAD")) + require.True(t, IsRebaseStartError(selected.Rebase("main", RebaseOpts{}))) + }) + } + + // Removing a nested checkout's gitfile must not fall back to its parent's + // HEAD/index just because Git can still discover the parent repository. + nestedPath := filepath.Join(dir, "nested") + gitExec(t, dir, "worktree", "add", "-b", "nested", nestedPath, "main") + nested := root.ForWorktree(nestedPath) + _, err := nested.GitDir() + require.NoError(t, err) + require.NoError(t, os.Rename(filepath.Join(nestedPath, ".git"), filepath.Join(t.TempDir(), "saved-gitfile"))) + require.ErrorContains(t, nested.ResetHard("HEAD"), "selected Git directory") + _, err = nested.HasUncommittedChanges() + require.Error(t, err) + assert.Equal(t, "main", gitExec(t, dir, "branch", "--show-current")) +} + +func TestIntegration_ForWorktreeIndependentExecutors(t *testing.T) { + root, dir := setupWorktreeRepo(t) + first, _ := addTestWorktree(t, root, dir, "first") + second, _ := addTestWorktree(t, root, dir, "second") + results := make(chan error, 2) + for branch, scope := range map[string]Ops{"first": first, "second": second} { + go func(branch string, scope Ops) { + for i := 0; i < 3; i++ { + got, err := scope.CurrentBranch() + if err != nil { + results <- err + return + } + if got != branch { + results <- fmt.Errorf("wanted branch %s, got %s", branch, got) + return + } + } + results <- nil + }(branch, scope) + } + require.NoError(t, <-results) + require.NoError(t, <-results) + assert.Equal(t, "main", gitExec(t, dir, "branch", "--show-current")) +} + +func TestIntegration_WorktreeRebasePreservesOtherRefsAndConfig(t *testing.T) { + for _, onto := range []bool{false, true} { + for _, dates := range []bool{false, true} { + t.Run(fmt.Sprintf("onto=%t/dates=%t", onto, dates), func(t *testing.T) { + root, dir := setupWorktreeRepo(t) + linked, path := addTestWorktree(t, root, dir, "feature") + base := gitExec(t, dir, "rev-parse", "main") + writeFile(t, path, "feature.txt", "first") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "--date=2001-01-01T00:00:00Z", "-m", "first") + excluded := gitExec(t, path, "rev-parse", "HEAD") + gitExec(t, path, "branch", "excluded") + writeFile(t, path, "feature.txt", "second") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "--date=2002-01-01T00:00:00Z", "-m", "second") + original := gitExec(t, path, "rev-parse", "HEAD") + writeFile(t, dir, "main.txt", "updated") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "main update") + mainHead := gitExec(t, dir, "rev-parse", "HEAD") + writeFile(t, dir, "unrelated.txt", "leave the initiating worktree alone") + mainStatus := gitExec(t, dir, "status", "--porcelain") + gitExec(t, dir, "worktree", "lock", path) + gitExec(t, dir, "config", "rebase.updateRefs", "true") + gitExec(t, dir, "config", "rebase.autoStash", "true") + + opts := RebaseOpts{CommitterDateIsAuthorDate: dates} + var err error + if onto { + err = linked.RebaseOnto("main", base, "feature", opts) + } else { + err = linked.Rebase("main", opts) + } + require.NoError(t, err) + assert.NotEqual(t, original, gitExec(t, path, "rev-parse", "HEAD")) + assert.Equal(t, excluded, gitExec(t, dir, "rev-parse", "excluded")) + assert.Equal(t, mainHead, gitExec(t, dir, "rev-parse", "HEAD")) + assert.Equal(t, mainStatus, gitExec(t, dir, "status", "--porcelain")) + assert.Equal(t, "feature", gitExec(t, path, "branch", "--show-current")) + assert.Equal(t, "true", gitExec(t, dir, "config", "--get", "rebase.updateRefs")) + assert.Equal(t, "true", gitExec(t, dir, "config", "--get", "rebase.autoStash")) + timestamps := strings.Fields(gitExec(t, path, "show", "-s", "--format=%at %ct", "HEAD")) + require.Len(t, timestamps, 2) + assert.Equal(t, dates, timestamps[0] == timestamps[1]) + }) + } + } +} + +func TestIntegration_WorktreeRebaseNeverAutostashes(t *testing.T) { + for _, onto := range []bool{false, true} { + for _, staged := range []bool{false, true} { + t.Run(fmt.Sprintf("onto=%t/staged=%t", onto, staged), func(t *testing.T) { + root, dir := setupWorktreeRepo(t) + linked, path := addTestWorktree(t, root, dir, "feature") + base := gitExec(t, dir, "rev-parse", "main") + writeFile(t, path, "feature.txt", "committed") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "-m", "feature") + original := gitExec(t, path, "rev-parse", "HEAD") + writeFile(t, dir, "main.txt", "main") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "main") + writeFile(t, path, "feature.txt", "uncommitted") + if staged { + gitExec(t, path, "add", ".") + } + status := gitExec(t, path, "status", "--porcelain") + // Git -c overrides must win over inherited command configuration, + // not just repository configuration. + t.Setenv("GIT_CONFIG_COUNT", "2") + t.Setenv("GIT_CONFIG_KEY_0", "rebase.autoStash") + t.Setenv("GIT_CONFIG_VALUE_0", "true") + t.Setenv("GIT_CONFIG_KEY_1", "rebase.updateRefs") + t.Setenv("GIT_CONFIG_VALUE_1", "true") + var err error + if onto { + err = linked.RebaseOnto("main", base, "feature", RebaseOpts{}) + } else { + err = linked.Rebase("main", RebaseOpts{}) + } + require.Error(t, err) + assert.True(t, IsRebaseStartError(err)) + assert.False(t, linked.IsRebaseInProgress()) + assert.Equal(t, original, gitExec(t, path, "rev-parse", "HEAD")) + assert.Equal(t, status, gitExec(t, path, "status", "--porcelain")) + assert.Empty(t, gitExec(t, path, "stash", "list")) + }) + } + } +} + +func setupWorktreeConflict(t *testing.T) (*defaultOps, string, Ops, string) { + t.Helper() + root, dir := setupWorktreeRepo(t) + linked, path := addTestWorktree(t, root, dir, "feature") + writeFile(t, path, "init.txt", "feature version\n") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "--date=2001-01-01T00:00:00Z", "-m", "feature conflict") + writeFile(t, dir, "init.txt", "main version\n") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "--date=2002-01-01T00:00:00Z", "-m", "main conflict") + return root, dir, linked, path +} + +func forbidGlobalWorktreeQueries(t *testing.T) func() { + t.Helper() + return SetOps(&MockOps{ + GitDirFn: func() (string, error) { + t.Error("real scoped operations must not query global mock GitDir") + return "", fmt.Errorf("global GitDir must not be called") + }, + IsRebaseInProgressFn: func() bool { + t.Error("real scoped operations must not query global mock rebase state") + return false + }, + ConflictedFilesFn: func() ([]string, error) { + t.Error("real scoped operations must not query global mock conflicts") + return nil, fmt.Errorf("global ConflictedFiles must not be called") + }, + }) +} + +func TestIntegration_WorktreeRebaseRecovery(t *testing.T) { + tests := []struct { + name string + main bool + abort bool + backend string + dates bool + }{ + {name: "linked continue", backend: "merge", dates: true}, + {name: "linked abort", abort: true, backend: "merge", dates: true}, + {name: "main continue", main: true, backend: "merge", dates: true}, + {name: "main abort", main: true, abort: true, backend: "merge", dates: true}, + {name: "apply continue", backend: "apply"}, + {name: "apply abort", abort: true, backend: "apply"}, + {name: "author dates with configured apply backend", backend: "apply", dates: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root, dir, linked, linkedPath := setupWorktreeConflict(t) + gitExec(t, dir, "config", "rebase.backend", tt.backend) + t.Setenv("GIT_EDITOR", "false") + target, observer := linked, root.ForWorktree(dir) + targetPath, observerPath := linkedPath, dir + branch, base := "feature", "main" + if tt.main { + target, observer = observer, target + targetPath, observerPath = dir, linkedPath + branch, base = base, branch + } + original := gitExec(t, targetPath, "rev-parse", "HEAD") + observerHead := gitExec(t, observerPath, "rev-parse", "HEAD") + writeFile(t, observerPath, "leave-alone.txt", "dirty unrelated worktree") + observerStatus := gitExec(t, observerPath, "status", "--porcelain") + restore := forbidGlobalWorktreeQueries(t) + defer restore() + + opts := RebaseOpts{CommitterDateIsAuthorDate: tt.dates} + err := target.Rebase(base, opts) + require.Error(t, err) + assert.False(t, IsRebaseStartError(err)) + require.True(t, target.IsRebaseInProgress()) + assert.False(t, observer.IsRebaseInProgress()) + assert.True(t, IsRebaseStartError(target.Rebase(base, opts))) + conflicts, err := target.ConflictedFiles() + require.NoError(t, err) + assert.Equal(t, []string{"init.txt"}, conflicts) + markers, err := target.FindConflictMarkers("init.txt") + require.NoError(t, err) + assert.Equal(t, []ConflictSection{{StartLine: 1, EndLine: 5}}, markers.Sections) + worktrees, err := observer.Worktrees() + require.NoError(t, err) + var reserved Worktree + for _, wt := range worktrees { + if wt.Path == canonicalGitTestPath(t, targetPath) { + reserved = wt + } + } + assert.Equal(t, branch, reserved.Branch) + assert.True(t, reserved.Detached) + + if tt.abort { + require.NoError(t, target.RebaseAbort()) + assert.Equal(t, original, gitExec(t, targetPath, "rev-parse", "HEAD")) + } else { + writeFile(t, targetPath, "init.txt", "resolved\n") + require.NoError(t, target.StageAll()) + require.NoError(t, target.RebaseContinue(opts)) + assert.NotEqual(t, original, gitExec(t, targetPath, "rev-parse", "HEAD")) + dates := strings.Fields(gitExec(t, targetPath, "show", "-s", "--format=%at %ct", "HEAD")) + require.Len(t, dates, 2) + assert.Equal(t, tt.dates, dates[0] == dates[1]) + } + assert.False(t, target.IsRebaseInProgress()) + assert.Equal(t, branch, gitExec(t, targetPath, "branch", "--show-current")) + assert.Equal(t, observerHead, gitExec(t, observerPath, "rev-parse", "HEAD")) + assert.Equal(t, observerStatus, gitExec(t, observerPath, "status", "--porcelain")) + }) + } +} + +func TestIntegration_WorktreeRerereAutoContinuesMultipleCommits(t *testing.T) { + root, dir := setupWorktreeRepo(t) + for _, file := range []string{"one.txt", "two.txt"} { + writeFile(t, dir, file, "initial "+file+"\n") + } + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "base") + linked, path := addTestWorktree(t, root, dir, "feature") + for _, file := range []string{"one.txt", "two.txt"} { + writeFile(t, path, file, "feature "+file+"\n") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "-m", file) + writeFile(t, dir, file, "main "+file+"\n") + } + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "main changes") + original := gitExec(t, path, "rev-parse", "HEAD") + mainHead := gitExec(t, dir, "rev-parse", "HEAD") + require.NoError(t, linked.EnableRerere()) + t.Setenv("GIT_EDITOR", "false") + restore := forbidGlobalWorktreeQueries(t) + defer restore() + require.Error(t, linked.Rebase("main", RebaseOpts{})) + writeFile(t, path, "one.txt", "resolved one\n") + require.NoError(t, linked.StageAll()) + require.Error(t, linked.RebaseContinue(RebaseOpts{}), "the second commit has an unseen conflict") + writeFile(t, path, "two.txt", "resolved two\n") + require.NoError(t, linked.StageAll()) + require.NoError(t, linked.RebaseContinue(RebaseOpts{})) + require.NoError(t, linked.ResetHard(original)) + + require.NoError(t, linked.Rebase("main", RebaseOpts{}), "rerere must continue both commits in the linked worktree") + assert.False(t, linked.IsRebaseInProgress()) + assert.Equal(t, mainHead, gitExec(t, dir, "rev-parse", "HEAD")) + for _, file := range []string{"one.txt", "two.txt"} { + data, err := os.ReadFile(filepath.Join(path, file)) + require.NoError(t, err) + assert.Contains(t, string(data), "resolved") + } +} + +func TestIntegration_WorktreeRebaseContinueStopsWhenNoProgress(t *testing.T) { + _, _, linked, path := setupWorktreeConflict(t) + require.Error(t, linked.Rebase("main", RebaseOpts{})) + writeFile(t, path, "init.txt", "resolved\n") + require.NoError(t, linked.StageAll()) + t.Setenv("GIT_COMMITTER_NAME", "") + tracePath := filepath.Join(t.TempDir(), "trace") + t.Setenv("GIT_TRACE", tracePath) + require.Error(t, linked.RebaseContinue(RebaseOpts{})) + trace, err := os.ReadFile(tracePath) + require.NoError(t, err) + assert.LessOrEqual(t, strings.Count(string(trace), " rebase --continue"), 2) + assert.True(t, linked.IsRebaseInProgress()) + require.NoError(t, linked.RebaseAbort()) +} + +func TestIntegration_WorktreeConflictPathsAndMarkers(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not allow colons and newlines in filenames") + } + root, dir := setupWorktreeRepo(t) + file := "conflict:\nname: leftover conflict marker [literal] \u03bb.txt" + writeFile(t, dir, file, "initial\n") + writeFile(t, dir, ".gitattributes", "* conflict-marker-size=10\n") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "conflict base") + linked, path := addTestWorktree(t, root, dir, "feature") + writeFile(t, path, file, "feature\n") + gitExec(t, path, "add", ".") + gitExec(t, path, "commit", "-m", "feature") + writeFile(t, dir, file, "main\n") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "main") + require.Error(t, linked.Rebase("main", RebaseOpts{})) + conflicts, err := linked.ConflictedFiles() + require.NoError(t, err) + assert.Equal(t, []string{file}, conflicts) + // Two marker sections, including diff3's optional base marker, exercise + // native marker-size handling and paths that cannot be parsed by colons. + writeFile(t, path, file, "<<<<<<<<<< ours\none\n==========\ntwo\n>>>>>>>>>> theirs\nmiddle\n<<<<<<<<<< ours\nthree\n|||||||||| base\nbase\n==========\nfour\n>>>>>>>>>> theirs\n") + require.NoError(t, os.MkdirAll(filepath.Join(path, "subdir"), 0755)) + sub := linked.ForWorktree("subdir") + gitExec(t, dir, "config", "diff.relative", "true") + subConflicts, err := sub.ConflictedFiles() + require.NoError(t, err) + assert.Equal(t, []string{file}, subConflicts) + for _, name := range []string{file, filepath.Join(path, file)} { + markers, err := sub.FindConflictMarkers(name) + require.NoError(t, err) + assert.Equal(t, name, markers.File) + assert.Equal(t, []ConflictSection{{StartLine: 1, EndLine: 5}, {StartLine: 7, EndLine: 13}}, markers.Sections) + } + require.NoError(t, linked.RebaseAbort()) +} + +func TestIntegration_WorktreeRetainedRebaseOwner(t *testing.T) { + root, dir, linked, path := setupWorktreeConflict(t) + require.Error(t, linked.Rebase("main", RebaseOpts{})) + canonicalPath := canonicalGitTestPath(t, path) + require.NoError(t, os.Rename(path, path+"-moved")) + worktrees, err := root.Worktrees() + require.NoError(t, err) + assert.Contains(t, worktrees, Worktree{Path: canonicalPath, Branch: "feature", Detached: true, Prunable: true}) + _, err = linked.HasUncommittedChanges() + require.Error(t, err, "a missing checkout must not silently execute elsewhere") + gitExec(t, dir, "worktree", "repair", path+"-moved") + moved := root.ForWorktree(path + "-moved") + require.True(t, moved.IsRebaseInProgress()) + require.NoError(t, moved.RebaseAbort()) +} + +func TestIntegration_WorktreeCherryPickRecovery(t *testing.T) { + for _, action := range []string{"continue", "abort", "quit"} { + t.Run(action, func(t *testing.T) { + root, dir, linked, path := setupWorktreeConflict(t) + t.Setenv("GIT_EDITOR", "false") + original := gitExec(t, path, "rev-parse", "HEAD") + mainHead := gitExec(t, dir, "rev-parse", "HEAD") + restore := forbidGlobalWorktreeQueries(t) + defer restore() + require.Error(t, linked.CherryPick([]string{mainHead})) + require.True(t, linked.IsCherryPickInProgress()) + assert.False(t, root.IsCherryPickInProgress()) + assert.False(t, linked.IsRebaseInProgress()) + switch action { + case "continue": + writeFile(t, path, "init.txt", "resolved\n") + require.NoError(t, linked.StageAll()) + require.NoError(t, linked.CherryPickContinue()) + assert.NotEqual(t, original, gitExec(t, path, "rev-parse", "HEAD")) + case "abort": + require.NoError(t, linked.CherryPickAbort()) + assert.Equal(t, original, gitExec(t, path, "rev-parse", "HEAD")) + case "quit": + require.NoError(t, linked.CherryPickQuit()) + conflicts, err := linked.ConflictedFiles() + require.NoError(t, err) + assert.NotEmpty(t, conflicts) + require.NoError(t, linked.ResetHard(original)) + } + assert.False(t, linked.IsCherryPickInProgress()) + assert.Equal(t, mainHead, gitExec(t, dir, "rev-parse", "HEAD")) + assert.Equal(t, "feature", gitExec(t, path, "branch", "--show-current")) + }) + } +} + +func TestIntegration_WorktreeLocalMutations(t *testing.T) { + root, dir := setupWorktreeRepo(t) + linked, path := addTestWorktree(t, root, dir, "feature") + restore := forbidGlobalWorktreeQueries(t) + defer restore() + defaultBranch, err := linked.DefaultBranch() + require.NoError(t, err) + assert.Equal(t, "main", defaultBranch) + initial := gitExec(t, dir, "rev-parse", "HEAD") + writeFile(t, dir, "new.txt", "new main commit\n") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "advance main") + mainHead := gitExec(t, dir, "rev-parse", "HEAD") + gitExec(t, dir, "config", "merge.autoStash", "true") + require.NoError(t, linked.MergeFF("main")) + assert.Equal(t, mainHead, gitExec(t, path, "rev-parse", "HEAD")) + require.NoError(t, linked.ResetHard(initial)) + assert.NoFileExists(t, filepath.Join(path, "new.txt")) + assert.FileExists(t, filepath.Join(dir, "new.txt")) + assert.Equal(t, mainHead, gitExec(t, dir, "rev-parse", "HEAD")) + require.Error(t, linked.CheckoutBranch("main")) + require.Error(t, linked.UpdateBranchRef("main", initial)) + require.NoError(t, linked.RenameBranch("feature", "renamed")) + branch, err := linked.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "renamed", branch) + + writeFile(t, path, "init.txt", "tracked change") + writeFile(t, path, "untracked.txt", "untracked change") + gitExec(t, dir, "config", "status.showUntrackedFiles", "no") + require.NoError(t, linked.StageTracked()) + assert.Equal(t, "init.txt", gitExec(t, path, "diff", "--cached", "--name-only")) + assert.Empty(t, gitExec(t, dir, "diff", "--cached", "--name-only")) + require.NoError(t, linked.StageAll()) + assert.True(t, linked.HasStagedChanges()) + _, err = linked.Commit("commit only in linked worktree") + require.NoError(t, err) + assert.False(t, linked.HasStagedChanges()) + assert.Equal(t, mainHead, gitExec(t, dir, "rev-parse", "HEAD")) + writeFile(t, path, "hidden-untracked.txt", "still dirty despite user status configuration") + dirty, err := linked.HasUncommittedChanges() + require.NoError(t, err) + assert.True(t, dirty) +} + +func TestIntegration_WorktreeCherryPickPendingSequencer(t *testing.T) { + root, dir, linked, path := setupWorktreeConflict(t) + first := gitExec(t, dir, "rev-parse", "HEAD") + writeFile(t, dir, "second.txt", "second commit") + gitExec(t, dir, "add", ".") + gitExec(t, dir, "commit", "-m", "second") + second := gitExec(t, dir, "rev-parse", "HEAD") + require.Error(t, linked.CherryPick([]string{first, second})) + writeFile(t, path, "init.txt", "manual resolution\n") + require.NoError(t, linked.StageAll()) + _, err := linked.Commit("resolve first pick manually") + require.NoError(t, err) + gitDir, err := linked.GitDir() + require.NoError(t, err) + assert.NoFileExists(t, filepath.Join(gitDir, "CHERRY_PICK_HEAD")) + assert.True(t, linked.IsCherryPickInProgress(), "the remaining sequencer still owns the worktree") + assert.False(t, root.IsCherryPickInProgress()) + require.NoError(t, linked.CherryPickContinue()) + assert.False(t, linked.IsCherryPickInProgress()) + assert.FileExists(t, filepath.Join(path, "second.txt")) + assert.Equal(t, second, gitExec(t, dir, "rev-parse", "HEAD")) +} + +func TestIntegration_WorktreeIgnoresCallerRepositoryEnvironment(t *testing.T) { + root, dir, linked, path := setupWorktreeConflict(t) + mainHead := gitExec(t, dir, "rev-parse", "HEAD") + gitDir, err := root.GitDir() + require.NoError(t, err) + t.Setenv("GIT_DIR", gitDir) + t.Setenv("GIT_COMMON_DIR", gitDir) + t.Setenv("GIT_WORK_TREE", dir) + t.Setenv("GIT_INDEX_FILE", filepath.Join(gitDir, "index")) + t.Setenv("GIT_EDITOR", "false") + // A newly selected scope and an existing one must both ignore the caller's + // local repository environment, including during continuation. + selected := root.ForWorktree(path) + for _, scope := range []Ops{selected, linked} { + branch, err := scope.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "feature", branch) + } + err = selected.Rebase("main", RebaseOpts{}) + require.Error(t, err) + assert.False(t, IsRebaseStartError(err)) + writeFile(t, path, "init.txt", "resolved\n") + require.NoError(t, selected.StageAll()) + require.NoError(t, selected.RebaseContinue(RebaseOpts{})) + head, err := root.RevParse("HEAD") + require.NoError(t, err) + assert.Equal(t, mainHead, head) + branch, err := selected.CurrentBranch() + require.NoError(t, err) + assert.Equal(t, "feature", branch) +} diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 25396f5d..7a2c4750 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -7,6 +7,10 @@ import "fmt" // Ops method call. When nil, a reasonable default is returned. type MockOps struct { GitDirFn func() (string, error) + CommonDirFn func() (string, error) + WorktreesFn func() ([]Worktree, error) + ForWorktreeFn func(string) Ops + CheckVersionFn func() error RootDirFn func() (string, error) CurrentBranchFn func() (string, error) BranchExistsFn func(string) bool @@ -74,6 +78,34 @@ func (m *MockOps) GitDir() (string, error) { return "/tmp/fake-git-dir", nil } +func (m *MockOps) CommonDir() (string, error) { + if m.CommonDirFn != nil { + return m.CommonDirFn() + } + return m.GitDir() +} + +func (m *MockOps) Worktrees() ([]Worktree, error) { + if m.WorktreesFn != nil { + return m.WorktreesFn() + } + return nil, nil +} + +func (m *MockOps) ForWorktree(path string) Ops { + if m.ForWorktreeFn != nil { + return m.ForWorktreeFn(path) + } + return m +} + +func (m *MockOps) CheckVersion() error { + if m.CheckVersionFn != nil { + return m.CheckVersionFn() + } + return nil +} + func (m *MockOps) RootDir() (string, error) { if m.RootDirFn != nil { return m.RootDirFn() diff --git a/internal/git/worktree.go b/internal/git/worktree.go new file mode 100644 index 00000000..78c5e6e9 --- /dev/null +++ b/internal/git/worktree.go @@ -0,0 +1,379 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + cligit "github.com/cli/cli/v2/git" +) + +// Worktree describes a registered checkout, including the main worktree. +type Worktree struct { + Path string + Branch string // Short branch name, including a branch reserved by a rebase. + Detached bool + Bare bool + Locked bool + Prunable bool +} + +func (d *defaultOps) path(args ...string) (string, error) { + out, err := d.runRaw(args...) + if err != nil { + return "", err + } + // Remove only Git's terminator: whitespace and newlines can belong to paths. + return strings.TrimSuffix(out, "\n"), nil +} + +func (d *defaultOps) CommonDir() (string, error) { + return d.path("rev-parse", "--path-format=absolute", "--git-common-dir") +} + +func (d *defaultOps) gitClient() (*cligit.Client, error) { + if d.scopeErr != nil { + return nil, d.scopeErr + } + c := d.client + if c == nil { + c = client + } + for _, directory := range []struct { + option string + info os.FileInfo + }{ + {"--absolute-git-dir", d.gitDir}, + {"--git-common-dir", d.commonDir}, + } { + if directory.info == nil { + continue + } + cmd, err := c.Command(context.Background(), "rev-parse", "--path-format=absolute", directory.option) + if err != nil { + return nil, err + } + d.configureCommand(cmd) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("inspecting worktree %q: %w", c.RepoDir, err) + } + info, err := os.Stat(strings.TrimSuffix(string(out), "\n")) + if err != nil { + return nil, fmt.Errorf("inspecting worktree %q: %w", c.RepoDir, err) + } + if !os.SameFile(directory.info, info) { + return nil, fmt.Errorf("worktree %q no longer refers to the selected Git directory; rediscover worktrees before continuing", c.RepoDir) + } + } + return c, nil +} + +// ForWorktree resolves relative paths against this receiver's execution +// directory. It never changes the process directory or the shared Git client. +func (d *defaultOps) ForWorktree(path string) Ops { + scoped := &defaultOps{} + if path == "" { + scoped.scopeErr = errors.New("worktree path must not be empty") + return scoped + } + c, err := d.gitClient() + if err != nil { + scoped.scopeErr = err + return scoped + } + common, err := d.CommonDir() + if err != nil { + scoped.scopeErr = fmt.Errorf("locating the source repository: %w", err) + return scoped + } + expectedCommon, err := os.Stat(common) + if err != nil { + scoped.scopeErr = err + return scoped + } + if !filepath.IsAbs(path) && c.RepoDir != "" { + path = filepath.Join(c.RepoDir, path) + } + path, err = filepath.Abs(path) + if err != nil { + scoped.scopeErr = err + return scoped + } + scoped.client = c.Copy() + scoped.client.RepoDir = path + scoped.scoped = true + + pathInfo, err := os.Stat(path) + if err != nil { + scoped.scopeErr = fmt.Errorf("opening worktree %q: %w", path, err) + return scoped + } + if os.SameFile(pathInfo, expectedCommon) { + bare, err := scoped.run("--git-dir="+common, "rev-parse", "--is-bare-repository") + if err != nil { + scoped.scopeErr = err + return scoped + } + if bare != "true" { + scoped.scopeErr = fmt.Errorf("cannot use Git administration directory %q as the main worktree; run this command from the main worktree or configure its core.worktree backlink", path) + return scoped + } + } + selectedCommon, err := scoped.CommonDir() + if err != nil { + scoped.scopeErr = fmt.Errorf("opening worktree %q: %w", path, err) + return scoped + } + commonInfo, err := os.Stat(selectedCommon) + if err != nil { + scoped.scopeErr = err + return scoped + } + if !os.SameFile(expectedCommon, commonInfo) { + scoped.scopeErr = fmt.Errorf("worktree %q belongs to a different Git repository", path) + return scoped + } + gitDir, err := scoped.GitDir() + if err != nil { + scoped.scopeErr = err + return scoped + } + scoped.gitDir, scoped.scopeErr = os.Stat(gitDir) + scoped.commonDir = commonInfo + return scoped +} + +func (d *defaultOps) CheckVersion() error { + version, err := d.run("--version") + if err != nil { + return fmt.Errorf("cannot determine Git version: %w; install Git 2.36 or newer and ensure it is on PATH", err) + } + return checkGitVersion(version) +} + +func checkGitVersion(version string) error { + number, found := strings.CutPrefix(version, "git version ") + fields := strings.Fields(number) + if !found || len(fields) == 0 { + return fmt.Errorf("cannot parse Git version %q; install Git 2.36 or newer and check `git --version`", version) + } + parts := strings.Split(fields[0], ".") + if len(parts) < 2 { + return fmt.Errorf("cannot parse Git version %q; install Git 2.36 or newer and check `git --version`", version) + } + major, majorErr := strconv.Atoi(parts[0]) + minor, minorErr := strconv.Atoi(parts[1]) + if majorErr != nil || minorErr != nil || major < 0 || minor < 0 { + return fmt.Errorf("cannot parse Git version %q; install Git 2.36 or newer and check `git --version`", version) + } + if major < 2 || (major == 2 && minor < 36) { + return fmt.Errorf("Git 2.36 or newer is required for worktree support (found %s); upgrade Git and check `git --version`", version) + } + return nil +} + +func (d *defaultOps) Worktrees() ([]Worktree, error) { + if err := d.CheckVersion(); err != nil { + return nil, err + } + out, err := d.runRaw("worktree", "list", "--porcelain", "-z") + if err != nil { + return nil, err + } + worktrees, err := parseWorktrees(out) + if err != nil { + return nil, err + } + if len(worktrees) > 0 && !worktrees[0].Bare { + if err := d.resolveMainWorktree(&worktrees[0]); err != nil { + return nil, err + } + } + var gitDirs map[string]string + for i := range worktrees { + wt := &worktrees[i] + if !wt.Detached || wt.Bare { + continue + } + if gitDirs == nil { + gitDirs, err = d.worktreeGitDirs(worktrees) + if err != nil { + return nil, err + } + } + gitDir, ok := gitDirs[filepath.Clean(wt.Path)] + if !ok { + return nil, fmt.Errorf("cannot locate Git directory for worktree %q; retry after worktree changes finish", wt.Path) + } + wt.Branch, err = rebaseBranch(gitDir) + if err != nil { + return nil, fmt.Errorf("inspecting rebase in worktree %q: %w", wt.Path, err) + } + } + return worktrees, nil +} + +func (d *defaultOps) resolveMainWorktree(main *Worktree) error { + common, err := d.CommonDir() + if err != nil { + return err + } + gitDir, err := d.GitDir() + if err != nil { + return err + } + commonInfo, err := os.Stat(common) + if err != nil { + return err + } + gitDirInfo, err := os.Stat(gitDir) + if err != nil { + return err + } + if os.SameFile(commonInfo, gitDirInfo) { + main.Path, err = d.RootDir() + return err + } + + // Porcelain infers the main path from the common directory, which is + // wrong for --separate-git-dir. Only use a backlink Git actually supplies. + backlink, err := d.mainWorktreeBacklink(common) + if err != nil { + return err + } + if backlink != "" { + main.Path = backlink + } + return nil +} + +func (d *defaultOps) mainWorktreeBacklink(common string) (string, error) { + c, err := d.gitClient() + if err != nil { + return "", err + } + main := &defaultOps{client: c.Copy(), scoped: true} + main.client.RepoDir = common + // Select the main Git directory explicitly so extensions.worktreeConfig + // reads its config.worktree, not the invoking linked worktree's config. + out, err := main.runRaw("--git-dir="+common, "config", "--null", "--path", "--get", "core.worktree") + if err != nil { + var gitErr *cligit.GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 { + return "", nil + } + return "", fmt.Errorf("reading main worktree backlink in %q: %w", common, err) + } + path := strings.TrimSuffix(out, "\x00") + if path == "" { + return "", nil + } + if !filepath.IsAbs(path) { + path = filepath.Join(common, path) + } + return filepath.ToSlash(filepath.Clean(path)), nil +} + +func parseWorktrees(output string) ([]Worktree, error) { + var worktrees []Worktree + var current *Worktree + for output != "" { + field, rest, terminated := strings.Cut(output, "\x00") + if !terminated { + return nil, errors.New("invalid git worktree output: missing NUL terminator") + } + output = rest + if field == "" { + if current != nil { + worktrees = append(worktrees, *current) + current = nil + } + continue + } + key, value, _ := strings.Cut(field, " ") + if key == "worktree" { + if current != nil || value == "" { + return nil, errors.New("invalid git worktree output: missing record separator or path") + } + current = &Worktree{Path: value} + continue + } + if current == nil { + return nil, fmt.Errorf("invalid git worktree output: %q precedes worktree path", key) + } + switch key { + case "branch": + branch, ok := strings.CutPrefix(value, "refs/heads/") + if !ok || branch == "" { + return nil, fmt.Errorf("invalid git worktree branch %q", value) + } + current.Branch = branch + case "detached": + current.Detached = true + case "bare": + current.Bare = true + case "locked": + current.Locked = true + case "prunable": + current.Prunable = true + } + } + if current != nil { + return nil, errors.New("invalid git worktree output: unterminated worktree record") + } + return worktrees, nil +} + +// Read retained administration directories too: a missing/prunable checkout can +// still reserve a branch while its native rebase state exists. +func (d *defaultOps) worktreeGitDirs(worktrees []Worktree) (map[string]string, error) { + common, err := d.CommonDir() + if err != nil { + return nil, err + } + dirs := map[string]string{filepath.Clean(worktrees[0].Path): common} + entries, err := os.ReadDir(filepath.Join(common, "worktrees")) + if errors.Is(err, os.ErrNotExist) { + return dirs, nil + } + if err != nil { + return nil, err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + gitDir := filepath.Join(common, "worktrees", entry.Name()) + data, err := os.ReadFile(filepath.Join(gitDir, "gitdir")) + if err != nil { + return nil, fmt.Errorf("reading worktree administration directory %q: %w", gitDir, err) + } + gitFile := strings.TrimSuffix(string(data), "\n") + if !filepath.IsAbs(gitFile) { + gitFile = filepath.Join(gitDir, gitFile) + } + dirs[filepath.Dir(gitFile)] = gitDir + } + return dirs, nil +} + +func rebaseBranch(gitDir string) (string, error) { + for _, name := range []string{"rebase-merge", "rebase-apply"} { + data, err := os.ReadFile(filepath.Join(gitDir, name, "head-name")) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", err + } + if branch, ok := strings.CutPrefix(strings.TrimSuffix(string(data), "\n"), "refs/heads/"); ok { + return branch, nil + } + } + return "", nil +} diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go new file mode 100644 index 00000000..70ca8fc8 --- /dev/null +++ b/internal/git/worktree_test.go @@ -0,0 +1,217 @@ +package git + +import ( + "errors" + "os/exec" + "path/filepath" + "strings" + "testing" + + cligit "github.com/cli/cli/v2/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseWorktrees(t *testing.T) { + tests := []struct { + name string + input string + want []Worktree + errMsg string + }{ + {name: "empty"}, + { + name: "main and linked", + input: "worktree /main\x00HEAD abc\x00branch refs/heads/main\x00\x00worktree /linked\x00HEAD def\x00branch refs/heads/feature\x00\x00", + want: []Worktree{ + {Path: "/main", Branch: "main"}, + {Path: "/linked", Branch: "feature"}, + }, + }, + { + name: "bare and detached", + input: "worktree /bare.git\x00bare\x00\x00worktree /detached\x00HEAD abc\x00detached\x00\x00", + want: []Worktree{ + {Path: "/bare.git", Bare: true}, + {Path: "/detached", Detached: true}, + }, + }, + { + name: "unquoted unusual paths and reasons", + input: "worktree /a \"quoted\" \u03bb\tpath\nwith\nnewlines \x00HEAD abc\x00branch refs/heads/feature-\u03bb\x00locked reason\nworktree /not-a-record\x00prunable reason\nmore text\x00\x00", + want: []Worktree{{ + Path: "/a \"quoted\" \u03bb\tpath\nwith\nnewlines ", Branch: "feature-\u03bb", + Locked: true, Prunable: true, + }}, + }, + { + name: "boolean attributes without reasons", + input: "worktree /linked\x00HEAD abc\x00detached\x00locked\x00prunable\x00\x00", + want: []Worktree{{Path: "/linked", Detached: true, Locked: true, Prunable: true}}, + }, + { + name: "unknown attributes remain forward compatible", + input: "worktree /main\x00new-attribute arbitrary value\x00branch refs/heads/main\x00\x00", + want: []Worktree{{Path: "/main", Branch: "main"}}, + }, + { + name: "long path is not scanner limited", + input: "worktree /" + strings.Repeat("a", 100000) + "\x00bare\x00\x00", + want: []Worktree{{Path: "/" + strings.Repeat("a", 100000), Bare: true}}, + }, + {name: "newline porcelain is rejected", input: "worktree /main\nbranch refs/heads/main\n\n", errMsg: "missing NUL"}, + {name: "missing path", input: "worktree \x00\x00", errMsg: "path"}, + {name: "attribute before path", input: "HEAD abc\x00\x00", errMsg: "precedes worktree"}, + {name: "missing record separator", input: "worktree /main\x00worktree /linked\x00\x00", errMsg: "record separator"}, + {name: "truncated record", input: "worktree /main\x00branch refs/heads/main\x00", errMsg: "unterminated"}, + {name: "nonbranch ref", input: "worktree /main\x00branch refs/tags/tag\x00\x00", errMsg: "branch"}, + {name: "empty branch", input: "worktree /main\x00branch refs/heads/\x00\x00", errMsg: "branch"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseWorktrees(tt.input) + if tt.errMsg != "" { + require.ErrorContains(t, err, tt.errMsg) + assert.Nil(t, got) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCheckGitVersion(t *testing.T) { + tests := []struct { + version string + errMsg string + }{ + {version: "git version 2.36.0"}, + {version: "git version 2.36"}, + {version: "git version 2.50.1 (Apple Git-155)"}, + {version: "git version 2.40.0.windows.1"}, + {version: "git version 2.36.0-rc2"}, + {version: "git version 3.0.0"}, + {version: "git version 2.35.99", errMsg: "upgrade Git"}, + {version: "git version 1.99.99", errMsg: "upgrade Git"}, + {version: "", errMsg: "cannot parse"}, + {version: "git version ", errMsg: "cannot parse"}, + {version: "2.36.0", errMsg: "cannot parse"}, + {version: "git version unknown", errMsg: "cannot parse"}, + {version: "git version 2.-36.0", errMsg: "cannot parse"}, + {version: "git version two.36.0", errMsg: "cannot parse"}, + {version: "git version 2.thirty-six.0", errMsg: "cannot parse"}, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + err := checkGitVersion(tt.version) + if tt.errMsg == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.errMsg) + assert.Contains(t, err.Error(), "2.36") + assert.Contains(t, err.Error(), "git --version") + }) + } +} + +func TestCheckVersionWithoutRepository(t *testing.T) { + d := &defaultOps{client: &cligit.Client{RepoDir: t.TempDir()}} + require.NoError(t, d.CheckVersion()) + + d.client.GitPath = filepath.Join(t.TempDir(), "missing-git") + err := d.CheckVersion() + require.ErrorContains(t, err, "cannot determine Git version") + assert.Contains(t, err.Error(), "install Git 2.36 or newer") + assert.Contains(t, err.Error(), "PATH") +} + +func TestMockWorktreeDefaults(t *testing.T) { + m := &MockOps{GitDirFn: func() (string, error) { return "/fixture/git", nil }} + common, err := m.CommonDir() + require.NoError(t, err) + assert.Equal(t, "/fixture/git", common) + assert.Same(t, m, m.ForWorktree("/fixture/linked")) + worktrees, err := m.Worktrees() + require.NoError(t, err) + assert.Nil(t, worktrees) + require.NoError(t, m.CheckVersion()) + + wantErr := errors.New("fixture error") + m.GitDirFn = func() (string, error) { return "", wantErr } + _, err = m.CommonDir() + require.ErrorIs(t, err, wantErr) +} + +func TestWorktreeWrappersDelegate(t *testing.T) { + wantErr := errors.New("hook error") + wantWorktrees := []Worktree{{Path: "/main", Branch: "main"}} + child := &MockOps{} + m := &MockOps{ + CommonDirFn: func() (string, error) { return "/common", wantErr }, + WorktreesFn: func() ([]Worktree, error) { return wantWorktrees, wantErr }, + ForWorktreeFn: func(path string) Ops { + assert.Equal(t, "/linked", path) + return child + }, + CheckVersionFn: func() error { return wantErr }, + } + restore := SetOps(m) + defer restore() + + common, err := CommonDir() + assert.Equal(t, "/common", common) + require.ErrorIs(t, err, wantErr) + worktrees, err := Worktrees() + assert.Equal(t, wantWorktrees, worktrees) + require.ErrorIs(t, err, wantErr) + assert.Same(t, child, ForWorktree("/linked")) + require.ErrorIs(t, CheckVersion(), wantErr) + assert.Same(t, m, CurrentOps()) +} + +func TestRebaseArgs(t *testing.T) { + for _, date := range []bool{false, true} { + args := rebaseArgs(RebaseOpts{CommitterDateIsAuthorDate: date}) + want := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "rebase"} + if date { + want = append(want, "--merge", "--committer-date-is-author-date") + } + assert.Equal(t, want, args) + } +} + +func TestScopedCommandEnvironment(t *testing.T) { + env := []string{ + "PATH=/bin", + "GIT_DIR=/caller/git", + "Git_Work_Tree=/caller", + "git_index_file=/caller/index", + "GIT_OBJECT_DIRECTORY=/caller/objects", + "GIT_CONFIG=/caller/config", + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=rebase.autoStash", + "GIT_CONFIG_VALUE_0=true", + "GIT_EDITOR=false", + } + for _, scoped := range []bool{false, true} { + d := &defaultOps{scoped: scoped} + cmd := &cligit.Command{Cmd: &exec.Cmd{Env: append([]string(nil), env...)}} + d.configureCommand(cmd) + if !scoped { + assert.Equal(t, env, cmd.Env) + continue + } + assert.Subset(t, cmd.Env, []string{ + "PATH=/bin", + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=rebase.autoStash", + "GIT_CONFIG_VALUE_0=true", + "GIT_EDITOR=false", + }) + for _, entry := range env[1:6] { + assert.NotContains(t, cmd.Env, entry) + } + } +} diff --git a/internal/modify/apply.go b/internal/modify/apply.go index d7621287..7db46044 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() plan := BuildPlan(nodes) @@ -143,10 +150,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} @@ -162,11 +171,6 @@ func ApplyPlan( } originalRefs, err := git.RevParseMap(branchNames) if err != nil { - // Unwind on failure - unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan) - if unwindErr != nil { - return nil, nil, fmt.Errorf("failed to resolve refs (%v) and unwind failed (%v)", err, unwindErr) - } return nil, nil, fmt.Errorf("failed to resolve branch SHAs: %w", err) } @@ -186,18 +190,39 @@ 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 + } + if ops.BranchExists(newName) { + 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 @@ -225,6 +250,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) } } @@ -282,13 +311,28 @@ 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 + } + if ops.BranchExists(newName) { + 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 @@ -322,6 +366,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) } @@ -381,15 +429,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)) @@ -397,13 +452,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 @@ -422,19 +474,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)) @@ -454,6 +505,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 @@ -479,6 +534,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) } @@ -546,127 +605,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) - } - - conflict := &modifyview.ConflictInfo{ - Branch: b.Branch, + if ancestor { + base, err := ops.MergeBase(newBase, name) + if err != nil { + return moved, nil, fmt.Errorf("finding merge base for %s: %w", name, err) } - if files, ferr := git.ConflictedFiles(); ferr == nil { - conflict.ConflictedFiles = files + if base == oldBase { + continue } - - if b.PullRequest != nil { - affectsPRs = true + } + 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" } - - // 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) - } + if saveErr := saveProgress(dir, state, s, sf); saveErr != nil { + return moved, nil, errors.Join(err, saveErr) } - 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 @@ -798,38 +843,47 @@ 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") + } + ctx, err := recoveryContext(gitDir, state) + if err != nil { + return err } - if s == nil { - return fmt.Errorf("stack at index %d not found (stack file may have changed)", state.StackIndex) + ops, err := ctx.OriginOps() + if err != nil { + return err + } + if state.Worktrees == nil { + if err := adoptLegacyContext(state, ctx, ops); 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) @@ -840,144 +894,51 @@ func ContinueApply( } case "", "rebase": // Rebase conflict - if git.IsRebaseInProgress() { - if err := git.RebaseContinue(git.RebaseOpts{}); err != nil { - return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) + if ops.IsRebaseInProgress() { + 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 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) } } @@ -985,25 +946,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") @@ -1016,76 +961,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. - if git.IsRebaseInProgress() { - _ = git.RebaseAbort() - } - if git.IsCherryPickInProgress() { - _ = git.CherryPickAbort() + state, err := LoadState(gitDir) + if err != nil { + return err } - - // Restore branch tips - snapshotNames := make(map[string]bool, len(snapshot.Branches)) - for _, bs := range snapshot.Branches { - snapshotNames[bs.Name] = true - if !git.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 - } + if state == nil { + state = &StateFile{ + SchemaVersion: 1, StackIndex: stackIndex, Phase: PhaseApplying, + Snapshot: snapshot, Plan: plan, } - } - - // 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] && git.BranchExists(action.NewName) { - _ = git.DeleteBranch(action.NewName, true) - } + } else { + var original stack.Stack + if err := json.Unmarshal(snapshot.StackMetadata, &original); err != nil { + return fmt.Errorf("reading recovery snapshot: %w", err) + } + 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). @@ -1103,5 +999,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 fc805aa0..b4f94745 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -4,7 +4,9 @@ import ( "encoding/json" "errors" "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/github/gh-stack/internal/config" @@ -12,6 +14,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 +48,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 { return true }, + BranchExistsFn: func(name string) bool { _, ok := branchSHAs[name]; return ok }, RevParseFn: func(ref string) (string, error) { if sha, ok := branchSHAs[ref]; ok { return sha, nil @@ -57,15 +60,22 @@ 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 { return false }, - 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 }, } } @@ -195,9 +205,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"}}, @@ -207,8 +215,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) }) } @@ -767,8 +774,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) @@ -798,20 +805,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 { return true } - mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil } + inProgress := true + mock.IsRebaseInProgressFn = func() bool { return inProgress } + 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 { @@ -879,12 +888,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 { return false }, BranchExistsFn: func(name string) bool { return true }, CheckoutBranchFn: func(name string) error { @@ -1058,19 +1070,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 { return true }, - IsRebaseInProgressFn: func() bool { return true }, + IsRebaseInProgressFn: func() bool { return inProgress }, RebaseContinueFn: func(git.RebaseOpts) error { rebaseContinueCalled = true + inProgress = false return nil }, RebaseOntoFn: func(newBase, oldBase, branch string, opts git.RebaseOpts) error { @@ -1179,10 +1194,13 @@ func TestUnwind_AbortsActiveRebase(t *testing.T) { })) var rebaseAbortCalled bool + inProgress := true mock := &git.MockOps{ - IsRebaseInProgressFn: func() bool { return true }, + GitDirFn: func() (string, error) { return gitDir, nil }, + IsRebaseInProgressFn: func() bool { return inProgress }, RebaseAbortFn: func() error { rebaseAbortCalled = true + inProgress = false return nil }, BranchExistsFn: func(string) bool { return true }, @@ -1234,11 +1252,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 { return false }, - IsCherryPickInProgressFn: func() bool { return true }, + IsCherryPickInProgressFn: func() bool { return inProgress }, 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 { return true }, CheckoutBranchFn: func(string) error { return nil }, ResetHardFn: func(string) error { return nil }, @@ -1296,6 +1316,7 @@ 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{ @@ -1364,20 +1385,23 @@ 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 { return true } - mock.RebaseContinueFn = func(git.RebaseOpts) error { return nil } + inProgress := false + mock.IsRebaseInProgressFn = func() bool { return inProgress } + 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 } } @@ -1444,6 +1468,7 @@ 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{ @@ -1518,6 +1543,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 { return false }, BranchExistsFn: func(name string) bool { return name != "A" // A was renamed away @@ -2191,7 +2217,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) @@ -2257,7 +2283,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) @@ -2315,3 +2341,585 @@ 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 { return inProgress } + 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)) + assert.False(t, mock.BranchExists("inserted")) + 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 { return failure == "abort" }, + 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(git.ForWorktree(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(git.ForWorktree(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(git.ForWorktree(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 := git.ForWorktree(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, originOps.IsRebaseInProgress()) + callerOps := originOps.ForWorktree(caller) + assert.False(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, 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(git.ForWorktree(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(git.ForWorktree(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 { return inProgress } + 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 40eabe76..6d1ddc2a 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 00000000..54190541 --- /dev/null +++ b/internal/modify/recovery.go @@ -0,0 +1,430 @@ +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 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) 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 && !ops.BranchExists(name) && ops.BranchExists(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 !ops.BranchExists(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 + } + } + 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 ops.IsRebaseInProgress() { + 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 ops.IsCherryPickInProgress() { + 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); err != nil { + return retain(err) + } + } else { + if err := recoverPendingAction(state, ops); 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 ops.BranchExists(newName) { + sha, err := ops.RevParse(newName) + if err != nil { + return retain(err) + } + if sha != ctx.Touched[newName] || ops.BranchExists(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)) + } + } 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 ops.BranchExists(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) error { + action := state.PendingAction + if action == nil { + return nil + } + switch action.Type { + case "rename": + if ops.BranchExists(action.NewName) && !ops.BranchExists(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 !ops.BranchExists(action.Branch) || ops.BranchExists(action.NewName) { + return fmt.Errorf("cannot identify interrupted rename of %s; recovery state was retained", action.Branch) + } + case "insert_below", "insert_above": + if ops.BranchExists(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) error { + for i := len(state.Plan) - 1; i >= 0; i-- { + action := state.Plan[i] + if action.Type == "rename" && !ops.BranchExists(action.Branch) && ops.BranchExists(action.NewName) { + if err := ops.RenameBranch(action.NewName, action.Branch); err != nil { + return fmt.Errorf("restoring renamed branch %s: %w", action.Branch, err) + } + } + } + for _, branch := range state.Snapshot.Branches { + if !ops.BranchExists(branch.Name) { + if err := ops.CreateBranch(branch.Name, branch.TipSHA); err != nil { + return fmt.Errorf("restoring branch %s: %w", branch.Name, err) + } + 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") && + ops.BranchExists(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 2660cba2..9975a8ad 100644 --- a/internal/modify/state.go +++ b/internal/modify/state.go @@ -6,7 +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" @@ -18,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"` @@ -87,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 } @@ -96,24 +109,87 @@ func SaveState(gitDir string, state *StateFile) error { if err != nil { return fmt.Errorf("marshaling modify state: %w", err) } - target := StatePath(gitDir) - tmp := target + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { + if err := stack.WriteAtomic(StatePath(gitDir), data); err != nil { return fmt.Errorf("writing modify state: %w", err) } - // Remove existing target before rename for Windows compatibility - // (os.Rename fails on Windows if the target already exists). - _ = os.Remove(target) - if err := os.Rename(tmp, target); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("committing modify state: %w", err) - } 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. @@ -128,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 @@ -139,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/atomic.go b/internal/stack/atomic.go new file mode 100644 index 00000000..f02fd127 --- /dev/null +++ b/internal/stack/atomic.go @@ -0,0 +1,66 @@ +package stack + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// WriteAtomic publishes data at path using a fully written temporary file in +// the same directory. It preserves an existing regular file's permissions and +// uses 0644 for a new file. The parent directory must already exist. +// It does not acquire locks; callers must serialize mutations. +func WriteAtomic(path string, data []byte) error { + return writeFileAtomic(path, data, 0644, true) +} + +// writeFileAtomic publishes a fully written sibling of path. When replace is +// false, an existing destination is never overwritten, even on a racing create. +func writeFileAtomic(path string, data []byte, mode os.FileMode, replace bool) (err error) { + if replace { + info, statErr := os.Lstat(path) + switch { + case statErr == nil: + if !info.Mode().IsRegular() { + return fmt.Errorf("cannot replace non-regular file %q", path) + } + mode = info.Mode().Perm() + case !errors.Is(statErr, os.ErrNotExist): + return statErr + } + } + + f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + temp := f.Name() + closed := false + defer func() { + if !closed { + err = errors.Join(err, f.Close()) + } + if removeErr := os.Remove(temp); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, fmt.Errorf("removing temporary file: %w", removeErr)) + } + }() + + if err := f.Chmod(mode); err != nil { + return err + } + if _, err := f.Write(data); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + closed = true + if err := f.Close(); err != nil { + return err + } + if err := publishFile(temp, path, replace); err != nil { + return err + } + return syncDirectory(filepath.Dir(path)) +} diff --git a/internal/stack/atomic_unix.go b/internal/stack/atomic_unix.go new file mode 100644 index 00000000..3a2f5c2d --- /dev/null +++ b/internal/stack/atomic_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package stack + +import ( + "errors" + "os" +) + +func readStateFile(path string) ([]byte, error) { + return os.ReadFile(path) +} + +func publishFile(temp, path string, replace bool) error { + if replace { + return os.Rename(temp, path) + } + // Linking publishes a complete file without replacing an existing backup. + // The temporary name is removed by writeFileAtomic. + return os.Link(temp, path) +} + +func syncDirectory(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + return errors.Join(f.Sync(), f.Close()) +} diff --git a/internal/stack/atomic_windows.go b/internal/stack/atomic_windows.go new file mode 100644 index 00000000..e76a9af7 --- /dev/null +++ b/internal/stack/atomic_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package stack + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +func readStateFile(path string) ([]byte, error) { + name, err := windowsFilePath(path) + if err != nil { + return nil, err + } + // Let publication replace the name while readers finish with the old file. + handle, err := windows.CreateFile(name, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + f := os.NewFile(uintptr(handle), path) + data, err := io.ReadAll(f) + return data, errors.Join(err, f.Close()) +} + +func publishFile(temp, path string, replace bool) error { + from, err := windowsFilePath(temp) + if err != nil { + return err + } + to, err := windowsFilePath(path) + if err != nil { + return err + } + flags := uint32(windows.MOVEFILE_WRITE_THROUGH) + if replace { + flags |= windows.MOVEFILE_REPLACE_EXISTING + } + // Never remove the destination first, or allow a cross-volume copy/delete. + if err := windows.MoveFileEx(from, to, flags); err != nil { + return &os.LinkError{Op: "publish", Old: temp, New: path, Err: err} + } + return nil +} + +func windowsFilePath(path string) (*uint16, error) { + path, err := filepath.Abs(path) + if err != nil { + return nil, err + } + switch { + case strings.HasPrefix(path, `\\?\`), strings.HasPrefix(path, `\\.\`): + case strings.HasPrefix(path, `\\`): + path = `\\?\UNC\` + path[2:] + default: + path = `\\?\` + path + } + return windows.UTF16PtrFromString(path) +} + +func syncDirectory(string) error { + // Windows does not expose directory fsync; publication uses WRITE_THROUGH. + return nil +} diff --git a/internal/stack/lock.go b/internal/stack/lock.go index 02a9dd94..ff06db63 100644 --- a/internal/stack/lock.go +++ b/internal/stack/lock.go @@ -1,15 +1,19 @@ package stack import ( + "errors" "fmt" "os" "path/filepath" "time" ) -const lockFileName = "gh-stack.lock" +const ( + lockFileName = "gh-stack.lock" + operationLockFileName = "gh-stack-operation.lock" +) -// LockError is returned when the stack file lock cannot be acquired. +// LockError is returned when the catalog or operation lock times out. // Callers can check for this with errors.As to distinguish lock failures // from other errors. type LockError struct { @@ -29,16 +33,13 @@ type StaleError struct { func (e *StaleError) Error() string { return e.Err.Error() } func (e *StaleError) Unwrap() error { return e.Err } -// LockTimeout is how long Lock() will wait for the exclusive lock before -// giving up. With the lock held only during file writes (milliseconds), -// this timeout primarily guards against a hung process holding the lock. +// LockTimeout is how long Lock and LockOperation wait for an exclusive lock. var LockTimeout = 5 * time.Second // lockRetryInterval is the sleep between non-blocking lock attempts. const lockRetryInterval = 100 * time.Millisecond -// FileLock provides an exclusive advisory lock on the stack file to prevent -// concurrent writes between multiple gh-stack processes. +// FileLock provides an exclusive advisory catalog or operation lock. type FileLock struct { f *os.File } @@ -48,29 +49,50 @@ type FileLock struct { // // Most callers should not use Lock directly — stack.Save() acquires the lock // automatically. Use Lock only when you need to hold the lock across multiple -// operations (e.g. Load-Modify-Save as an atomic unit). +// operations (e.g. Load-Modify-SaveWithLock as an atomic unit). func Lock(gitDir string) (*FileLock, error) { - path := filepath.Join(gitDir, lockFileName) + lock, _, err := acquireLock(filepath.Join(gitDir, lockFileName), "stack", true) + return lock, err +} + +// LockOperation serializes mutations in a repository's resolved common +// directory. Acquire it before loading mutation state or taking the catalog +// lock. Save only takes the catalog lock, so it may be used while this is held. +func LockOperation(commonDir string) (*FileLock, error) { + lock, _, err := acquireLock(filepath.Join(commonDir, operationLockFileName), "stack operation", true) + return lock, err +} + +// TryLockOperation attempts to acquire the operation lock without waiting. +// A false result with no error means contention; other failures are returned. +func TryLockOperation(commonDir string) (*FileLock, bool, error) { + return acquireLock(filepath.Join(commonDir, operationLockFileName), "stack operation", false) +} + +func acquireLock(path, name string, wait bool) (*FileLock, bool, error) { f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) if err != nil { - return nil, fmt.Errorf("opening lock file: %w", err) + return nil, false, fmt.Errorf("opening lock file: %w", err) } deadline := time.Now().Add(LockTimeout) for { err := tryLockFile(f) if err == nil { - return &FileLock{f: f}, nil + return &FileLock{f: f}, true, nil } if !isLockBusy(err) { - // Unexpected error (e.g. bad fd) — don't retry. - f.Close() - return nil, fmt.Errorf("locking stack file: %w", err) + return nil, false, fmt.Errorf("locking %s file: %w", name, errors.Join(err, f.Close())) + } + if !wait { + if err := f.Close(); err != nil { + return nil, false, fmt.Errorf("closing lock file: %w", err) + } + return nil, false, nil } if time.Now().After(deadline) { - f.Close() - return nil, &LockError{Err: fmt.Errorf( - "timed out waiting for stack lock after %s — another gh-stack process may be running", LockTimeout)} + return nil, false, &LockError{Err: errors.Join(fmt.Errorf( + "timed out waiting for %s lock after %s — another gh-stack process may be running", name, LockTimeout), f.Close())} } time.Sleep(lockRetryInterval) } @@ -86,4 +108,5 @@ func (l *FileLock) Unlock() { } unlockFile(l.f) l.f.Close() + l.f = nil } diff --git a/internal/stack/lock_test.go b/internal/stack/lock_test.go index 6afefc60..10c9cd1a 100644 --- a/internal/stack/lock_test.go +++ b/internal/stack/lock_test.go @@ -5,7 +5,10 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "strings" "sync" + "sync/atomic" "testing" "time" @@ -242,3 +245,342 @@ func TestSave_DoubleSaveSucceeds(t *testing.T) { require.NoError(t, err) assert.Len(t, final.Stacks, 2) } + +func TestLockOperation_IndependentCatalogLock(t *testing.T) { + dir := t.TempDir() + operation, err := LockOperation(dir) + require.NoError(t, err) + defer operation.Unlock() + + sf, err := Load(dir) + require.NoError(t, err) + sf.AddStack(makeStack("main", "feature")) + require.NoError(t, Save(dir, sf), "Save must not retake the operation lock") + + catalog, err := Lock(dir) + require.NoError(t, err) + defer catalog.Unlock() + sf.AddStack(makeStack("main", "other")) + require.NoError(t, SaveWithLock(dir, sf, catalog)) + + start := time.Now() + contender, acquired, err := TryLockOperation(dir) + require.NoError(t, err) + assert.False(t, acquired) + assert.Nil(t, contender) + assert.Less(t, time.Since(start), time.Second) + + operation.Unlock() + operation.Unlock() + contender, acquired, err = TryLockOperation(dir) + require.NoError(t, err) + require.True(t, acquired, "catalog lock must not prevent an operation lock") + contender.Unlock() + + assert.FileExists(t, filepath.Join(dir, operationLockFileName)) + assert.FileExists(t, filepath.Join(dir, lockFileName)) +} + +func TestTryLockOperation_Errors(t *testing.T) { + for _, kind := range []string{"missing directory", "directory at lock path"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + if kind == "missing directory" { + dir = filepath.Join(dir, "missing") + } else { + require.NoError(t, os.Mkdir(filepath.Join(dir, operationLockFileName), 0755)) + } + lock, acquired, err := TryLockOperation(dir) + require.Error(t, err) + assert.Nil(t, lock) + assert.False(t, acquired) + var lockErr *LockError + assert.False(t, errors.As(err, &lockErr), "real I/O failure is not contention") + }) + } + + t.Run("invalid descriptor is not contention", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "lock") + require.NoError(t, err) + require.NoError(t, f.Close()) + err = tryLockFile(f) + require.Error(t, err) + assert.False(t, isLockBusy(err)) + }) +} + +func TestLockOperation_TimesOut(t *testing.T) { + dir := t.TempDir() + lock, err := LockOperation(dir) + require.NoError(t, err) + defer lock.Unlock() + originalTimeout := LockTimeout + LockTimeout = 100 * time.Millisecond + defer func() { LockTimeout = originalTimeout }() + + other, err := LockOperation(dir) + require.Error(t, err) + assert.Nil(t, other) + var lockErr *LockError + require.ErrorAs(t, err, &lockErr) + assert.Contains(t, err.Error(), "stack operation lock") +} + +func TestLockOperation_SerializesMutationSnapshots(t *testing.T) { + dir := t.TempDir() + errs := make(chan error, 4) + var wg sync.WaitGroup + for i := range 4 { + wg.Go(func() { + lock, err := LockOperation(dir) + if err != nil { + errs <- err + return + } + defer lock.Unlock() + sf, err := Load(dir) + if err != nil { + errs <- err + return + } + sf.AddStack(makeStack("main", fmt.Sprintf("branch-%d", i))) + errs <- Save(dir, sf) + }) + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + sf, err := Load(dir) + require.NoError(t, err) + assert.Len(t, sf.Stacks, 4) +} + +func TestSaveNonBlocking_OperationAndCatalogGuards(t *testing.T) { + for _, kind := range []string{"uncontended", "operation lock", "catalog lock", "stale", "pending migration"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + sf := &StackFile{Stacks: []Stack{makeStack("main", "original")}} + require.NoError(t, Save(dir, sf)) + refresh, err := Load(dir) + require.NoError(t, err) + refresh.Stacks[0].Branches[0].Head = "metadata-refresh" + checksum := append([]byte(nil), refresh.loadChecksum...) + + var held *FileLock + switch kind { + case "operation lock": + held, err = LockOperation(dir) + case "catalog lock": + held, err = Lock(dir) + case "stale": + sf.Stacks[0].Branches[0].Head = "critical-write" + err = Save(dir, sf) + case "pending migration": + err = os.WriteFile(filepath.Join(dir, migrationFileName), []byte("{}"), 0600) + } + require.NoError(t, err) + defer held.Unlock() + + start := time.Now() + SaveNonBlocking(dir, refresh) + assert.Less(t, time.Since(start), time.Second) + held.Unlock() + got, err := Load(dir) + require.NoError(t, err) + if kind == "uncontended" { + assert.Equal(t, "metadata-refresh", got.Stacks[0].Branches[0].Head) + assert.NotEqual(t, checksum, refresh.loadChecksum) + } else { + assert.NotEqual(t, "metadata-refresh", got.Stacks[0].Branches[0].Head) + assert.Equal(t, checksum, refresh.loadChecksum) + } + lock, acquired, err := TryLockOperation(dir) + require.NoError(t, err) + require.True(t, acquired, "a skipped refresh must release the operation lock") + lock.Unlock() + }) + } +} + +func TestSave_AtomicReaderVisibility(t *testing.T) { + dir := t.TempDir() + sf := &StackFile{Stacks: []Stack{makeStack("main", "feature")}} + require.NoError(t, Save(dir, sf)) + stop := make(chan struct{}) + errs := make(chan error, 2) + var reads atomic.Int64 + var readers sync.WaitGroup + for range 2 { + readers.Go(func() { + for { + select { + case <-stop: + return + default: + } + got, err := Load(dir) + if err != nil { + errs <- err + return + } + if len(got.Stacks) != 1 || len(got.Stacks[0].Branches) != 1 || + got.Stacks[0].Trunk.Head != got.Stacks[0].Branches[0].Base { + errs <- fmt.Errorf("reader observed an incomplete catalog: %#v", got) + return + } + reads.Add(1) + } + }) + } + var writeErr error + for i := range 50 { + head := strings.Repeat(fmt.Sprintf("%04d", i), 1024) + sf.Stacks[0].Trunk.Head = head + sf.Stacks[0].Branches[0].Base = head + if writeErr = Save(dir, sf); writeErr != nil { + break + } + } + close(stop) + readers.Wait() + close(errs) + require.NoError(t, writeErr) + for err := range errs { + require.NoError(t, err) + } + assert.Positive(t, reads.Load()) + temps, err := filepath.Glob(filepath.Join(dir, "."+stackFileName+"-*")) + require.NoError(t, err) + assert.Empty(t, temps) +} + +func TestAtomicPublication_PreservesExistingFiles(t *testing.T) { + t.Run("no overwrite on exclusive publication", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup") + require.NoError(t, writeFileAtomic(path, []byte("original"), 0600, false)) + err := writeFileAtomic(path, []byte("replacement"), 0600, false) + require.ErrorIs(t, err, os.ErrExist) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "original", string(data)) + temps, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".backup-*")) + require.NoError(t, err) + assert.Empty(t, temps) + }) + + t.Run("failed replace keeps destination", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "destination") + require.NoError(t, os.WriteFile(path, []byte("original"), 0600)) + require.Error(t, publishFile(filepath.Join(dir, "missing"), path, true)) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "original", string(data)) + }) + + t.Run("non-regular destination is not replaced", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "destination") + require.NoError(t, os.Mkdir(path, 0755)) + require.Error(t, writeFileAtomic(path, []byte("replacement"), 0600, true)) + assert.DirExists(t, path) + }) + + t.Run("mode survives replacement", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits") + } + dir := t.TempDir() + sf := &StackFile{Stacks: []Stack{makeStack("main", "feature")}} + require.NoError(t, Save(dir, sf)) + require.NoError(t, os.Chmod(stackFilePath(dir), 0640)) + sf.AddStack(makeStack("main", "other")) + require.NoError(t, Save(dir, sf)) + info, err := os.Stat(stackFilePath(dir)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0640), info.Mode().Perm()) + }) +} + +func TestWriteAtomic(t *testing.T) { + t.Run("creates and replaces exact bytes", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "gh-stack-rebase-state") + for _, data := range [][]byte{ + []byte(`{"phase":"rebasing","branch":"feature"}`), + []byte(`{"phase":"done"}`), + {0x00, 0xff, 0x0a}, + {}, + } { + require.NoError(t, WriteAtomic(path, data)) + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, data, got) + } + temps, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".gh-stack-rebase-state-*")) + require.NoError(t, err) + assert.Empty(t, temps) + }) + + t.Run("missing parent is reported", func(t *testing.T) { + parent := filepath.Join(t.TempDir(), "missing") + require.ErrorIs(t, WriteAtomic(filepath.Join(parent, "state"), []byte("{}")), os.ErrNotExist) + assert.NoDirExists(t, parent) + }) + + t.Run("non-regular target is preserved", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state") + require.NoError(t, os.Mkdir(path, 0755)) + require.Error(t, WriteAtomic(path, []byte("{}"))) + assert.DirExists(t, path) + }) +} + +func TestSave_FailedPublicationPreservesChecksum(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("requires Unix directory permission enforcement") + } + dir := t.TempDir() + sf := &StackFile{Stacks: []Stack{makeStack("main", "original")}} + require.NoError(t, Save(dir, sf)) + checksum := append([]byte(nil), sf.loadChecksum...) + before, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + info, err := os.Stat(dir) + require.NoError(t, err) + require.NoError(t, os.Chmod(dir, 0500)) + t.Cleanup(func() { assert.NoError(t, os.Chmod(dir, info.Mode().Perm())) }) + + sf.AddStack(makeStack("main", "unsaved")) + require.Error(t, Save(dir, sf)) + assert.Equal(t, checksum, sf.loadChecksum) + after, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + assert.Equal(t, before, after) + require.NoError(t, os.Chmod(dir, info.Mode().Perm())) + require.NoError(t, Save(dir, sf), "a failed publication must remain retryable") +} + +func TestMigrateLegacyState_TakesCatalogLock(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))), + } + operation, err := LockOperation(dir) + require.NoError(t, err) + defer operation.Unlock() + catalog, err := Lock(dir) + require.NoError(t, err) + defer catalog.Unlock() + originalTimeout := LockTimeout + LockTimeout = 0 + defer func() { LockTimeout = originalTimeout }() + + var lockErr *LockError + require.ErrorAs(t, MigrateLegacyState(dir), &lockErr) + assertMigrationOriginals(t, dir, catalogs, false) + catalog.Unlock() + require.NoError(t, MigrateLegacyState(dir)) + assertMigrationOriginals(t, dir, catalogs, true) +} diff --git a/internal/stack/migration.go b/internal/stack/migration.go new file mode 100644 index 00000000..062a0e3e --- /dev/null +++ b/internal/stack/migration.go @@ -0,0 +1,485 @@ +package stack + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" +) + +const ( + migrationFileName = "gh-stack-migration" + migrationBackupSuffix = ".pre-worktree-migration" + migrationVersion = 1 +) + +// MigrationConflictError reports catalog definitions or migration artifacts +// that cannot be reconciled without choosing which tracking state to keep. +type MigrationConflictError struct { + Sources []string + Branches []string + Reason string +} + +func (e *MigrationConflictError) Error() string { + branches := "" + if len(e.Branches) > 0 { + branches = fmt.Sprintf("; branches: %q", e.Branches) + } + return fmt.Sprintf("cannot migrate stack catalogs: %s; sources: %q%s; reconcile or recreate the intended tracking state before retrying", e.Reason, e.Sources, branches) +} + +// MigrationBlockedError identifies legacy recovery records whose original +// catalogs must remain available to the operation's original worktree. +type MigrationBlockedError struct { + RecoveryPaths []string +} + +func (e *MigrationBlockedError) Error() string { + return fmt.Sprintf("stack migration is blocked by recovery state at %q; finish or abort rebase/modify in the original worktree before retrying migration", e.RecoveryPaths) +} + +type migrationCatalog struct { + Path string `json:"path"` + Data []byte `json:"data"` + Mode os.FileMode `json:"mode"` +} + +// Original bytes, including the old common catalog, must survive publication +// before any named backup is created. Relative administration paths also keep +// this record usable if the repository itself moves during an interruption. +type migrationState struct { + Version int `json:"version"` + Catalogs []migrationCatalog `json:"catalogs"` +} + +// HasLegacyState reports linked-worktree catalogs or an unfinished migration. +// It inspects every retained administration directory, regardless of whether +// its worktree still exists. It never invokes Git or changes worktree state. +func HasLegacyState(commonDir string) (bool, error) { + catalogs, err := legacyCatalogs(commonDir) + if err != nil { + return false, err + } + _, _, pending, err := readMigrationFile(filepath.Join(commonDir, migrationFileName)) + if err != nil { + return false, err + } + return pending || len(catalogs) > 0, nil +} + +// MigrateLegacyState consolidates legacy catalogs into the common directory. +// 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. +// Old and new gh-stack versions must not write catalogs concurrently. +func MigrateLegacyState(commonDir string) error { + lock, err := Lock(commonDir) + if err != nil { + return err + } + defer lock.Unlock() + + legacy, err := legacyCatalogs(commonDir) + if err != nil { + return err + } + journalPath := filepath.Join(commonDir, migrationFileName) + journal, _, pending, err := readMigrationFile(journalPath) + if err != nil { + return err + } + if !pending && len(legacy) == 0 { + return nil + } + + state := migrationState{Version: migrationVersion} + if pending { + state.Version = 0 + if err := decodeMigrationJSON(journal, &state); err != nil { + return fmt.Errorf("reading migration journal %q: %w", journalPath, err) + } + if state.Version != migrationVersion { + return fmt.Errorf("migration journal %q has unsupported version %d; preserve it and use the matching gh-stack version", journalPath, state.Version) + } + } else { + data, mode, exists, err := readMigrationFile(stackFilePath(commonDir)) + if err != nil { + return err + } + if exists { + state.Catalogs = append(state.Catalogs, migrationCatalog{Path: stackFileName, Data: data, Mode: mode}) + } + state.Catalogs = append(state.Catalogs, legacy...) + } + if err := validateMigrationPaths(state.Catalogs); err != nil { + return fmt.Errorf("invalid migration journal %q: %w", journalPath, err) + } + if err := checkMigrationRecovery(commonDir, state.Catalogs); err != nil { + return err + } + merged, err := mergeMigrationCatalogs(commonDir, state.Catalogs) + if err != nil { + return err + } + mergedData, err := marshalStackFile(merged) + if err != nil { + return err + } + + published, err := checkMigrationSnapshot(commonDir, state.Catalogs, legacy, mergedData) + if err != nil { + return err + } + if !pending { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("encoding migration journal: %w", err) + } + if err := writeFileAtomic(journalPath, data, 0600, false); err != nil { + return fmt.Errorf("publishing migration journal: %w", err) + } + } + if !published || !pending { + if err := writeStackFile(commonDir, merged); err != nil { + return err + } + } + + for _, catalog := range state.Catalogs { + path := filepath.Join(commonDir, filepath.FromSlash(catalog.Path)) + if err := backupMigrationCatalog(path, catalog); err != nil { + return err + } + if catalog.Path == stackFileName { + continue + } + data, _, exists, err := readMigrationFile(path) + if err != nil { + return err + } + if !exists { + continue // A prior attempt archived it; its backup was just checked. + } + if !bytes.Equal(data, catalog.Data) { + return migrationConflict("legacy catalog changed during migration", []string{path, journalPath}, nil) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("archiving legacy catalog %q: %w", path, err) + } + if err := syncDirectory(filepath.Dir(path)); err != nil { + return fmt.Errorf("syncing archived catalog directory: %w", err) + } + } + if err := os.Remove(journalPath); err != nil { + return fmt.Errorf("removing completed migration journal: %w", err) + } + return syncDirectory(commonDir) +} + +func legacyCatalogs(commonDir string) ([]migrationCatalog, error) { + info, err := os.Stat(commonDir) + if err != nil { + return nil, fmt.Errorf("inspecting common directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("common directory %q is not a directory", commonDir) + } + dir := filepath.Join(commonDir, "worktrees") + info, err = os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("inspecting worktree administration directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("worktree administration path %q is not a directory", dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("listing worktree administration directories: %w", err) + } + var catalogs []migrationCatalog + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return nil, fmt.Errorf("inspecting worktree administration path %q: %w", path, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("worktree administration path %q is not a directory", path) + } + data, mode, exists, err := readMigrationFile(filepath.Join(path, stackFileName)) + if err != nil { + return nil, err + } + if exists { + catalogs = append(catalogs, migrationCatalog{ + Path: filepath.ToSlash(filepath.Join("worktrees", entry.Name(), stackFileName)), + Data: data, + Mode: mode, + }) + } + } + return catalogs, nil +} + +func readMigrationFile(path string) ([]byte, os.FileMode, bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, 0, false, nil + } + if err != nil { + return nil, 0, false, fmt.Errorf("inspecting migration source %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, 0, false, fmt.Errorf("migration source %q is not a regular file", path) + } + data, err := readStateFile(path) + if err != nil { + return nil, 0, false, fmt.Errorf("reading migration source %q: %w", path, err) + } + return data, info.Mode().Perm(), true, nil +} + +func validateMigrationPaths(catalogs []migrationCatalog) error { + seen := make(map[string]bool) + hasLegacy := false + for _, catalog := range catalogs { + parts := strings.Split(catalog.Path, "/") + if catalog.Path != stackFileName { + if len(parts) != 3 || parts[0] != "worktrees" || parts[2] != stackFileName || + parts[1] == "" || parts[1] == "." || parts[1] == ".." || + filepath.Base(parts[1]) != parts[1] { + return fmt.Errorf("unsafe catalog path %q", catalog.Path) + } + hasLegacy = true + } + if !filepath.IsLocal(filepath.FromSlash(catalog.Path)) || seen[catalog.Path] { + return fmt.Errorf("invalid or duplicate catalog path %q", catalog.Path) + } + if catalog.Mode != catalog.Mode.Perm() { + return fmt.Errorf("invalid catalog permissions for %q", catalog.Path) + } + seen[catalog.Path] = true + } + if !hasLegacy { + return errors.New("migration contains no linked-worktree catalogs") + } + return nil +} + +func checkMigrationRecovery(commonDir string, catalogs []migrationCatalog) error { + dirs := []string{commonDir} + for _, catalog := range catalogs { + if catalog.Path != stackFileName { + dirs = append(dirs, filepath.Dir(filepath.Join(commonDir, filepath.FromSlash(catalog.Path)))) + } + } + var paths []string + for _, dir := range dirs { + for _, name := range []string{"gh-stack-rebase-state", "gh-stack-modify-state"} { + path := filepath.Join(dir, name) + _, err := os.Lstat(path) + if err == nil { + paths = append(paths, path) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspecting legacy recovery state %q: %w", path, err) + } + } + } + if len(paths) > 0 { + return &MigrationBlockedError{RecoveryPaths: paths} + } + return nil +} + +func decodeMigrationJSON(data []byte, value any) error { + if !json.Valid(data) { + return errors.New("invalid JSON") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(value) +} + +func parseMigrationCatalog(catalog migrationCatalog) (*StackFile, error) { + sf, err := parseStackFile(catalog.Data) + if err != nil { + return nil, err + } + // Refuse data that the normal catalog model would silently discard. + if err := decodeMigrationJSON(catalog.Data, sf); err != nil { + return nil, err + } + var catalogFields struct { + SchemaVersion *int `json:"schemaVersion"` + Stacks *[]json.RawMessage `json:"stacks"` + } + if err := json.Unmarshal(catalog.Data, &catalogFields); err != nil { + return nil, err + } + if catalogFields.SchemaVersion == nil || *catalogFields.SchemaVersion < 0 || catalogFields.Stacks == nil { + return nil, errors.New("catalog must contain schemaVersion and a stacks array") + } + for i, s := range sf.Stacks { + var stackFields map[string]json.RawMessage + if err := json.Unmarshal((*catalogFields.Stacks)[i], &stackFields); err != nil { + return nil, err + } + if s.Trunk.Branch == "" || stackFields["branches"] == nil { + return nil, errors.New("each stack must contain a named trunk and branches") + } + owned := map[string]bool{s.Trunk.Branch: true} + for _, branch := range s.Branches { + if branch.Branch == "" { + return nil, errors.New("stack contains an unnamed branch") + } + if owned[branch.Branch] { + return nil, &MigrationConflictError{Branches: []string{branch.Branch}, Reason: "branch is repeated within one stack"} + } + owned[branch.Branch] = true + } + } + return sf, nil +} + +func mergeMigrationCatalogs(commonDir string, catalogs []migrationCatalog) (*StackFile, error) { + merged := &StackFile{SchemaVersion: schemaVersion, Stacks: []Stack{}} + var sources []string + repositorySource := "" + for _, catalog := range catalogs { + path := filepath.Join(commonDir, filepath.FromSlash(catalog.Path)) + sf, err := parseMigrationCatalog(catalog) + if err != nil { + var conflict *MigrationConflictError + if errors.As(err, &conflict) { + conflict.Sources = []string{path} + } + return nil, fmt.Errorf("parsing migration catalog %q: %w", path, err) + } + if sf.Repository != "" { + if merged.Repository != "" && merged.Repository != sf.Repository { + return nil, migrationConflict("repository identities differ", []string{repositorySource, path}, nil) + } + merged.Repository = sf.Repository + repositorySource = path + } + for _, s := range sf.Stacks { + duplicate := false + for i, other := range merged.Stacks { + if equivalentMigrationStacks(s, other) { + duplicate = true + break + } + var shared []string + for _, branch := range s.Branches { + if other.IndexOf(branch.Branch) >= 0 { + shared = append(shared, branch.Branch) + } + } + sameIdentity := (s.ID != "" && s.ID == other.ID) || (s.Number != 0 && s.Number == other.Number) + if sameIdentity { + branches := append(s.BranchNames(), other.BranchNames()...) + return nil, migrationConflict("stack identity has differing definitions", []string{sources[i], path}, branches) + } + if len(shared) > 0 { + return nil, migrationConflict("non-trunk branches belong to differing stack definitions", []string{sources[i], path}, shared) + } + } + if !duplicate { + merged.Stacks = append(merged.Stacks, s) + sources = append(sources, path) + } + } + } + return merged, nil +} + +func equivalentMigrationStacks(a, b Stack) bool { + if len(a.Branches) == 0 { + a.Branches = nil + } + if len(b.Branches) == 0 { + b.Branches = nil + } + return reflect.DeepEqual(a, b) +} + +func migrationConflict(reason string, sources, branches []string) error { + slices.Sort(branches) + branches = slices.Compact(branches) + return &MigrationConflictError{Sources: sources, Branches: branches, Reason: reason} +} + +func checkMigrationSnapshot(commonDir string, catalogs, legacy []migrationCatalog, mergedData []byte) (bool, error) { + expected := make(map[string]migrationCatalog, len(catalogs)) + for _, catalog := range catalogs { + expected[catalog.Path] = catalog + } + for _, catalog := range legacy { + if _, ok := expected[catalog.Path]; !ok { + return false, migrationConflict("a new legacy catalog appeared during migration", []string{filepath.Join(commonDir, filepath.FromSlash(catalog.Path)), filepath.Join(commonDir, migrationFileName)}, nil) + } + } + + commonPath := stackFilePath(commonDir) + current, _, exists, err := readMigrationFile(commonPath) + if err != nil { + return false, err + } + published := exists && bytes.Equal(current, mergedData) + original, hadCommon := expected[stackFileName] + if !published && (exists != hadCommon || !bytes.Equal(current, original.Data)) { + return false, migrationConflict("common catalog changed during migration", []string{commonPath, filepath.Join(commonDir, migrationFileName)}, nil) + } + + for _, catalog := range catalogs { + path := filepath.Join(commonDir, filepath.FromSlash(catalog.Path)) + backup, _, backedUp, err := readMigrationFile(path + migrationBackupSuffix) + if err != nil { + return false, err + } + if backedUp && !bytes.Equal(backup, catalog.Data) { + return false, migrationConflict("existing backup differs from the original catalog", []string{path, path + migrationBackupSuffix}, nil) + } + if catalog.Path == stackFileName { + continue + } + data, _, exists, err := readMigrationFile(path) + if err != nil { + return false, err + } + if exists && !bytes.Equal(data, catalog.Data) { + return false, migrationConflict("legacy catalog changed during migration", []string{path, filepath.Join(commonDir, migrationFileName)}, nil) + } + if !exists && (!published || !backedUp) { + return false, migrationConflict("legacy catalog disappeared without a published common catalog and matching backup", []string{path, path + migrationBackupSuffix}, nil) + } + } + return published, nil +} + +func backupMigrationCatalog(path string, catalog migrationCatalog) error { + backupPath := path + migrationBackupSuffix + data, _, exists, err := readMigrationFile(backupPath) + if err != nil { + return err + } + if exists { + if !bytes.Equal(data, catalog.Data) { + return migrationConflict("existing backup differs from the original catalog", []string{path, backupPath}, nil) + } + return nil + } + if err := writeFileAtomic(backupPath, catalog.Data, catalog.Mode, false); err != nil { + return fmt.Errorf("preserving original catalog %q: %w", backupPath, err) + } + return nil +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index c03205ab..0e9db7e6 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,13 +310,14 @@ 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. func Load(gitDir string) (*StackFile, error) { path := stackFilePath(gitDir) - data, err := os.ReadFile(path) + data, err := readStateFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { // loadChecksum stays nil — sentinel for "file absent at load time". @@ -328,6 +329,10 @@ func Load(gitDir string) (*StackFile, error) { return nil, fmt.Errorf("reading stack file: %w", err) } + return parseStackFile(data) +} + +func parseStackFile(data []byte) (*StackFile, error) { var sf StackFile if err := json.Unmarshal(data, &sf); err != nil { return nil, fmt.Errorf("parsing stack file: %w", err) @@ -345,6 +350,8 @@ func Load(gitDir string) (*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. +// 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 { @@ -360,7 +367,8 @@ func Save(gitDir string, sf *StackFile) error { return writeStackFile(gitDir, sf) } -// SaveWithLock writes the stack file while the caller already holds the lock. +// SaveWithLock writes the stack file while the caller already holds the catalog +// lock (not merely the operation lock). // The caller is responsible for acquiring and releasing the lock. // Panics if lock is nil to catch programming errors. func SaveWithLock(gitDir string, sf *StackFile, lock *FileLock) error { @@ -370,22 +378,27 @@ func SaveWithLock(gitDir string, sf *StackFile, lock *FileLock) error { return writeStackFile(gitDir, sf) } -// SaveNonBlocking attempts to save without blocking. If another process holds -// the lock or the file was modified since Load, the save is silently skipped. -// Use this for best-effort metadata persistence (e.g. syncing PR state in view). +// SaveNonBlocking attempts a best-effort metadata refresh without waiting for +// either the operation or catalog lock. Contention, stale data and I/O errors +// skip the refresh. Use Save for critical writes, including callers already +// holding the operation lock. func SaveNonBlocking(gitDir string, sf *StackFile) { - path := filepath.Join(gitDir, lockFileName) - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { + operation, acquired, err := TryLockOperation(gitDir) + if err != nil || !acquired { return } - if tryLockFile(f) != nil { - f.Close() + defer operation.Unlock() + + lock, acquired, err := acquireLock(filepath.Join(gitDir, lockFileName), "stack", false) + if err != nil || !acquired { return } - lock := &FileLock{f: f} defer lock.Unlock() + // Do not change a published migration snapshot before its backups finish. + if _, err := os.Lstat(filepath.Join(gitDir, migrationFileName)); !errors.Is(err, os.ErrNotExist) { + return + } if checkStale(gitDir, sf) != nil { return } @@ -397,7 +410,7 @@ func SaveNonBlocking(gitDir string, sf *StackFile) { // by another process. The caller must hold the lock. func checkStale(gitDir string, sf *StackFile) error { path := stackFilePath(gitDir) - data, err := os.ReadFile(path) + data, err := readStateFile(path) if errors.Is(err, os.ErrNotExist) { // File absent on disk. @@ -428,16 +441,11 @@ func checkStale(gitDir string, sf *StackFile) error { } func writeStackFile(gitDir string, sf *StackFile) error { - sf.SchemaVersion = schemaVersion - if sf.Stacks == nil { - sf.Stacks = []Stack{} - } - data, err := json.MarshalIndent(sf, "", " ") + data, err := marshalStackFile(sf) if err != nil { - return fmt.Errorf("marshaling stack file: %w", err) + return err } - path := stackFilePath(gitDir) - if err := os.WriteFile(path, data, 0644); err != nil { + if err := WriteAtomic(stackFilePath(gitDir), data); err != nil { return fmt.Errorf("writing stack file: %w", err) } // Refresh checksum so a second Save on the same StackFile doesn't @@ -446,3 +454,15 @@ func writeStackFile(gitDir string, sf *StackFile) error { sf.loadChecksum = sum[:] return nil } + +func marshalStackFile(sf *StackFile) ([]byte, error) { + sf.SchemaVersion = schemaVersion + if sf.Stacks == nil { + sf.Stacks = []Stack{} + } + data, err := json.MarshalIndent(sf, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling stack file: %w", err) + } + return data, nil +} diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go index 73ddc408..0ae8e8ba 100644 --- a/internal/stack/stack_test.go +++ b/internal/stack/stack_test.go @@ -2,8 +2,11 @@ package stack import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -656,3 +659,711 @@ func TestNearestSurvivingBranch(t *testing.T) { }) } } + +func migrationTestFile(stacks ...Stack) StackFile { + if stacks == nil { + stacks = []Stack{} + } + return StackFile{SchemaVersion: schemaVersion, Repository: "github.com:owner/repo", Stacks: stacks} +} + +func migrationTestStack() Stack { + return Stack{ + ID: "stack-global-id", + Number: 17, + Trunk: BranchRef{Branch: "main", Head: "trunk-head", Base: "trunk-base"}, + Branches: []BranchRef{ + { + Branch: "feature/one", Head: "first-head", Base: "first-base", + PullRequest: &PullRequestRef{Number: 21, ID: "PR_one", URL: "https://example.com/pull/21", Merged: true}, + }, + { + Branch: "feature/two", Head: "second-head", Base: "second-base", + PullRequest: &PullRequestRef{Number: 22, ID: "PR_two", URL: "https://example.com/pull/22"}, + }, + }, + } +} + +func writeMigrationTestData(t *testing.T, dir, relative string, data []byte) migrationCatalog { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, data, 0644)) + info, err := os.Stat(path) + require.NoError(t, err) + return migrationCatalog{Path: relative, Data: data, Mode: info.Mode().Perm()} +} + +func writeMigrationTestCatalog(t *testing.T, dir, relative string, sf StackFile) migrationCatalog { + t.Helper() + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + return writeMigrationTestData(t, dir, relative, append(data, '\n')) +} + +func migrateWithTestOperationLock(t *testing.T, dir string) error { + t.Helper() + lock, err := LockOperation(dir) + require.NoError(t, err) + defer lock.Unlock() + return MigrateLegacyState(dir) +} + +func assertMigrationOriginals(t *testing.T, dir string, catalogs []migrationCatalog, migrated bool) { + t.Helper() + for _, catalog := range catalogs { + path := filepath.Join(dir, filepath.FromSlash(catalog.Path)) + if migrated { + if catalog.Path != stackFileName { + assert.NoFileExists(t, path) + } + path += migrationBackupSuffix + } else { + assert.NoFileExists(t, path+migrationBackupSuffix) + } + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, catalog.Data, data, "original bytes at %s", path) + } + assert.NoFileExists(t, filepath.Join(dir, migrationFileName)) +} + +func TestMigrateLegacyState_Merge(t *testing.T) { + detailed := migrationTestStack() + other := makeStack("main", "other") + other.Trunk.Head = "a-different-recorded-trunk-head" + empty := makeStack("main") + emptySlice := Stack{Trunk: BranchRef{Branch: "main"}, Branches: []BranchRef{}} + tests := []struct { + name string + common []Stack + legacy [][]Stack + want []Stack + }{ + {"absent common catalog", nil, [][]Stack{{detailed}, {other}}, []Stack{detailed, other}}, + {"disjoint common and linked catalogs", []Stack{detailed}, [][]Stack{{other}}, []Stack{detailed, other}}, + {"equivalent common and linked catalogs", []Stack{detailed}, [][]Stack{{detailed}}, []Stack{detailed}}, + {"equivalent linked catalogs", nil, [][]Stack{{detailed}, {detailed}}, []Stack{detailed}}, + {"deduplicate individual stacks", []Stack{detailed}, [][]Stack{{other, detailed}, {other}}, []Stack{detailed, other}}, + {"shared trunks with differing recorded heads", nil, [][]Stack{{detailed}, {other}}, []Stack{detailed, other}}, + {"a member can be another stacks trunk", []Stack{makeStack("main", "base")}, [][]Stack{{makeStack("base", "top")}}, []Stack{makeStack("main", "base"), makeStack("base", "top")}}, + {"equivalent empty branch lists", []Stack{empty}, [][]Stack{{emptySlice}}, []Stack{empty}}, + {"empty legacy catalog", nil, [][]Stack{{}}, []Stack{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + var catalogs []migrationCatalog + if tt.common != nil { + catalogs = append(catalogs, writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(tt.common...))) + } + for i, stacks := range tt.legacy { + relative := fmt.Sprintf("worktrees/admin id %d/gh-stack", i) + catalogs = append(catalogs, writeMigrationTestCatalog(t, dir, relative, migrationTestFile(stacks...))) + } + has, err := HasLegacyState(dir) + require.NoError(t, err) + require.True(t, has) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + + got, err := Load(dir) + require.NoError(t, err) + assert.Equal(t, schemaVersion, got.SchemaVersion) + assert.Equal(t, "github.com:owner/repo", got.Repository) + assert.Equal(t, tt.want, got.Stacks) + assertMigrationOriginals(t, dir, catalogs, true) + if tt.common == nil { + assert.NoFileExists(t, stackFilePath(dir)+migrationBackupSuffix) + } + first, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(first, &fields)) + assert.Len(t, fields, 3, "the shared catalog retains its ordinary schema") + has, err = HasLegacyState(dir) + require.NoError(t, err) + assert.False(t, has) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + second, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + assert.Equal(t, first, second) + assertMigrationOriginals(t, dir, catalogs, true) + }) + } +} + +func TestMigrateLegacyState_Conflicts(t *testing.T) { + tests := []struct { + name string + change func(*Stack) + }{ + {"same ID different branches", func(s *Stack) { s.Branches = []BranchRef{{Branch: "different"}} }}, + {"same number different ID and branches", func(s *Stack) { + s.ID = "different-id" + s.Branches = []BranchRef{{Branch: "different"}} + }}, + {"same branches different identities", func(s *Stack) { s.ID, s.Number = "different-id", 18 }}, + {"missing versus known identity", func(s *Stack) { s.ID, s.Number = "", 0 }}, + {"different recorded base", func(s *Stack) { s.Branches[0].Base = "new-base" }}, + {"different recorded head", func(s *Stack) { s.Branches[0].Head = "new-head" }}, + {"different trunk head", func(s *Stack) { s.Trunk.Head = "new-trunk-head" }}, + {"different trunk base", func(s *Stack) { s.Trunk.Base = "new-trunk-base" }}, + {"different PR number", func(s *Stack) { s.Branches[0].PullRequest.Number++ }}, + {"different PR ID", func(s *Stack) { s.Branches[0].PullRequest.ID = "new-PR-id" }}, + {"different PR URL", func(s *Stack) { s.Branches[0].PullRequest.URL = "https://example.com/new" }}, + {"different merge state", func(s *Stack) { s.Branches[0].PullRequest.Merged = false }}, + {"longer stack must not win", func(s *Stack) { s.Branches = append(s.Branches, BranchRef{Branch: "extra"}) }}, + {"different order", func(s *Stack) { s.Branches[0], s.Branches[1] = s.Branches[1], s.Branches[0] }}, + } + for _, tt := range tests { + for _, withCommon := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/common=%t", tt.name, withCommon), func(t *testing.T) { + dir := t.TempDir() + first := migrationTestStack() + second := migrationTestStack() + tt.change(&second) + firstPath := "worktrees/first/gh-stack" + if withCommon { + firstPath = stackFileName + } + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, firstPath, migrationTestFile(first)), + writeMigrationTestCatalog(t, dir, "worktrees/second/gh-stack", migrationTestFile(second)), + } + err := migrateWithTestOperationLock(t, dir) + var conflict *MigrationConflictError + require.ErrorAs(t, err, &conflict) + assert.ElementsMatch(t, []string{filepath.Join(dir, filepath.FromSlash(firstPath)), filepath.Join(dir, "worktrees", "second", stackFileName)}, conflict.Sources) + assert.Contains(t, conflict.Branches, "feature/one") + assert.Contains(t, err.Error(), "reconcile or recreate") + assertMigrationOriginals(t, dir, catalogs, false) + }) + } + } + + t.Run("overlapping local stacks without IDs", func(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "shared", "one"))), + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "shared", "two"))), + } + var conflict *MigrationConflictError + require.ErrorAs(t, migrateWithTestOperationLock(t, dir), &conflict) + assert.Equal(t, []string{"shared"}, conflict.Branches) + assertMigrationOriginals(t, dir, catalogs, false) + }) + + t.Run("duplicate branch within one stack", func(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "duplicate", "duplicate"))), + } + var conflict *MigrationConflictError + require.ErrorAs(t, migrateWithTestOperationLock(t, dir), &conflict) + assert.Equal(t, []string{"duplicate"}, conflict.Branches) + assert.Equal(t, []string{filepath.Join(dir, "worktrees", "linked", stackFileName)}, conflict.Sources) + assertMigrationOriginals(t, dir, catalogs, false) + }) +} + +func TestMigrateLegacyState_RepositoryIdentity(t *testing.T) { + for _, repositories := range [][2]string{ + {"github.com:owner/repo", "github.com:other/repo"}, + {"", "github.com:owner/repo"}, + {"github.com:owner/repo", ""}, + {"", ""}, + } { + t.Run(fmt.Sprintf("%q and %q", repositories[0], repositories[1]), func(t *testing.T) { + dir := t.TempDir() + first, second := migrationTestFile(makeStack("main", "one")), migrationTestFile(makeStack("main", "two")) + first.Repository, second.Repository = repositories[0], repositories[1] + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, stackFileName, first), + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", second), + } + err := migrateWithTestOperationLock(t, dir) + if repositories[0] != "" && repositories[1] != "" && repositories[0] != repositories[1] { + var conflict *MigrationConflictError + require.ErrorAs(t, err, &conflict) + assert.Contains(t, conflict.Reason, "repository") + assertMigrationOriginals(t, dir, catalogs, false) + return + } + require.NoError(t, err) + sf, err := Load(dir) + require.NoError(t, err) + want := repositories[0] + if want == "" { + want = repositories[1] + } + assert.Equal(t, want, sf.Repository) + assertMigrationOriginals(t, dir, catalogs, true) + }) + } +} + +func TestMigrateLegacyState_InvalidCatalogs(t *testing.T) { + tests := []struct { + name string + data string + }{ + {"malformed JSON", "{not JSON"}, + {"null catalog", "null"}, + {"array catalog", "[]"}, + {"future schema", `{"schemaVersion":999,"stacks":[]}`}, + {"negative schema", `{"schemaVersion":-1,"stacks":[]}`}, + {"missing schema", `{"stacks":[]}`}, + {"missing stacks", `{"schemaVersion":1}`}, + {"null stacks", `{"schemaVersion":1,"stacks":null}`}, + {"unknown metadata", `{"schemaVersion":1,"stacks":[],"futureMetadata":true}`}, + {"unnamed trunk", `{"schemaVersion":1,"stacks":[{"trunk":{},"branches":[]}]}`}, + {"missing branches", `{"schemaVersion":1,"stacks":[{"trunk":{"branch":"main"}}]}`}, + {"unnamed branch", `{"schemaVersion":1,"stacks":[{"trunk":{"branch":"main"},"branches":[{}]}]}`}, + {"unknown branch metadata", `{"schemaVersion":1,"stacks":[{"trunk":{"branch":"main"},"branches":[{"branch":"a","unknown":"value"}]}]}`}, + {"invalid PR type", `{"schemaVersion":1,"stacks":[{"trunk":{"branch":"main"},"branches":[{"branch":"a","pullRequest":{"number":"21"}}]}]}`}, + } + for _, tt := range tests { + for _, invalidCommon := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/common=%t", tt.name, invalidCommon), func(t *testing.T) { + dir := t.TempDir() + invalid, valid := "worktrees/invalid/gh-stack", stackFileName + if invalidCommon { + invalid, valid = stackFileName, "worktrees/valid/gh-stack" + } + catalogs := []migrationCatalog{ + writeMigrationTestData(t, dir, invalid, []byte(tt.data)), + writeMigrationTestCatalog(t, dir, valid, migrationTestFile(makeStack("main", "valid"))), + } + err := migrateWithTestOperationLock(t, dir) + require.Error(t, err) + assert.Contains(t, err.Error(), filepath.Join(dir, filepath.FromSlash(invalid))) + assertMigrationOriginals(t, dir, catalogs, false) + }) + } + } + + t.Run("older schema keeps existing load compatibility", func(t *testing.T) { + dir := t.TempDir() + catalog := writeMigrationTestData(t, dir, "worktrees/older/gh-stack", []byte(`{"schemaVersion":0,"stacks":[{"trunk":{"branch":"main"},"branches":[{"branch":"old"}]}]}`)) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + sf, err := Load(dir) + require.NoError(t, err) + assert.Equal(t, schemaVersion, sf.SchemaVersion) + assert.Equal(t, []Stack{makeStack("main", "old")}, sf.Stacks) + assertMigrationOriginals(t, dir, []migrationCatalog{catalog}, true) + }) +} + +func TestMigrateLegacyState_RecoveryBlocks(t *testing.T) { + for _, location := range []string{".", "worktrees/linked"} { + for _, name := range []string{"gh-stack-rebase-state", "gh-stack-modify-state"} { + t.Run(location+"/"+name, func(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "common"))), + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))), + } + recoveryPath := filepath.Join(dir, filepath.FromSlash(location), name) + require.NoError(t, os.WriteFile(recoveryPath, []byte("{even a damaged recovery record blocks migration"), 0600)) + err := migrateWithTestOperationLock(t, dir) + var blocked *MigrationBlockedError + require.ErrorAs(t, err, &blocked) + assert.Equal(t, []string{recoveryPath}, blocked.RecoveryPaths) + assert.Contains(t, err.Error(), "original worktree") + assertMigrationOriginals(t, dir, catalogs, false) + assert.FileExists(t, recoveryPath) + require.NoError(t, os.Remove(recoveryPath)) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + assertMigrationOriginals(t, dir, catalogs, true) + }) + } + } + + t.Run("common journal blocks even with no common catalog", func(t *testing.T) { + dir := t.TempDir() + catalog := writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack-rebase-state"), []byte("{}"), 0600)) + var blocked *MigrationBlockedError + require.ErrorAs(t, migrateWithTestOperationLock(t, dir), &blocked) + assert.NoFileExists(t, stackFilePath(dir)) + assertMigrationOriginals(t, dir, []migrationCatalog{catalog}, false) + }) + + t.Run("unrelated worktree without a catalog is not touched", func(t *testing.T) { + dir := t.TempDir() + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))) + recovery := writeMigrationTestData(t, dir, "worktrees/unrelated/gh-stack-modify-state", []byte("{}")) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(recovery.Path))) + require.NoError(t, err) + assert.Equal(t, recovery.Data, data) + }) +} + +func TestHasLegacyState_RetainedAdministrationDirectories(t *testing.T) { + dir := t.TempDir() + has, err := HasLegacyState(dir) + require.NoError(t, err) + assert.False(t, has) + common := writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "common"))) + has, err = HasLegacyState(dir) + require.NoError(t, err) + assert.False(t, has) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + assertMigrationOriginals(t, dir, []migrationCatalog{common}, false) + + legacy := writeMigrationTestCatalog(t, dir, "worktrees/unrelated-admin-id/gh-stack", migrationTestFile(makeStack("main", "linked"))) + adminDir := filepath.Dir(filepath.Join(dir, filepath.FromSlash(legacy.Path))) + gitdir := []byte(filepath.Join(dir, "missing worktree with different basename", ".git") + "\n") + require.NoError(t, os.WriteFile(filepath.Join(adminDir, "gitdir"), gitdir, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(adminDir, "locked"), []byte("retained worktree"), 0644)) + has, err = HasLegacyState(dir) + require.NoError(t, err) + assert.True(t, has) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + assertMigrationOriginals(t, dir, []migrationCatalog{common, legacy}, true) + data, err := os.ReadFile(filepath.Join(adminDir, "gitdir")) + require.NoError(t, err) + assert.Equal(t, gitdir, data) + assert.FileExists(t, filepath.Join(adminDir, "locked")) + assert.DirExists(t, adminDir) + has, err = HasLegacyState(dir) + require.NoError(t, err) + assert.False(t, has, "archived catalogs do not trigger re-import") + + require.NoError(t, os.WriteFile(filepath.Join(dir, migrationFileName), []byte("{}"), 0600)) + has, err = HasLegacyState(dir) + require.NoError(t, err) + assert.True(t, has, "a pending journal must be detected even after every catalog was archived") +} + +func TestLegacyState_MetadataErrors(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, string) + }{ + {"worktrees is a file", func(t *testing.T, dir string) { + require.NoError(t, os.WriteFile(filepath.Join(dir, "worktrees"), []byte("not a directory"), 0600)) + }}, + {"admin entry is a file", func(t *testing.T, dir string) { + writeMigrationTestData(t, dir, "worktrees/not-a-directory", []byte("metadata")) + }}, + {"catalog is a directory", func(t *testing.T, dir string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir, "worktrees", "linked", stackFileName), 0755)) + }}, + {"catalog is a dangling symlink", func(t *testing.T, dir string) { + admin := filepath.Join(dir, "worktrees", "linked") + require.NoError(t, os.MkdirAll(admin, 0755)) + if err := os.Symlink(filepath.Join(dir, "missing"), filepath.Join(admin, stackFileName)); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks unavailable: %v", err) + } + require.NoError(t, err) + } + }}, + {"symlinked admin is not silently skipped", func(t *testing.T, dir string) { + target := filepath.Join(dir, "target") + require.NoError(t, os.MkdirAll(target, 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "worktrees"), 0755)) + if err := os.Symlink(target, filepath.Join(dir, "worktrees", "linked")); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks unavailable: %v", err) + } + require.NoError(t, err) + } + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + _, err := HasLegacyState(dir) + require.Error(t, err) + require.Error(t, migrateWithTestOperationLock(t, dir)) + assert.NoFileExists(t, stackFilePath(dir)) + assert.NoFileExists(t, filepath.Join(dir, migrationFileName)) + }) + } + + t.Run("missing common directory", func(t *testing.T) { + _, err := HasLegacyState(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) + }) + + for _, inaccessible := range []string{"admin directory", "catalog"} { + t.Run("inaccessible "+inaccessible, func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("requires Unix permission enforcement") + } + dir := t.TempDir() + writeMigrationTestCatalog(t, dir, "worktrees/a-readable/gh-stack", migrationTestFile(makeStack("main", "readable"))) + catalog := writeMigrationTestCatalog(t, dir, "worktrees/z-inaccessible/gh-stack", migrationTestFile(makeStack("main", "hidden"))) + path := filepath.Join(dir, filepath.FromSlash(catalog.Path)) + if inaccessible == "admin directory" { + path = filepath.Dir(path) + } + info, err := os.Stat(path) + require.NoError(t, err) + require.NoError(t, os.Chmod(path, 0)) + t.Cleanup(func() { assert.NoError(t, os.Chmod(path, info.Mode().Perm())) }) + _, err = HasLegacyState(dir) + require.Error(t, err, "a readable catalog must not hide a later access failure") + require.Error(t, migrateWithTestOperationLock(t, dir)) + assert.NoFileExists(t, stackFilePath(dir)) + }) + } +} + +func TestMigrateLegacyState_BackupCollisions(t *testing.T) { + for _, relative := range []string{stackFileName, "worktrees/linked/gh-stack"} { + for _, kind := range []string{"equivalent bytes", "different bytes", "directory", "symlink"} { + t.Run(relative+"/"+kind, func(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "common"))), + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))), + } + path := filepath.Join(dir, filepath.FromSlash(relative)) + migrationBackupSuffix + original := catalogs[0].Data + if relative != stackFileName { + original = catalogs[1].Data + } + switch kind { + case "equivalent bytes": + require.NoError(t, os.WriteFile(path, original, 0600)) + case "different bytes": + require.NoError(t, os.WriteFile(path, []byte("do not overwrite this backup"), 0600)) + case "directory": + require.NoError(t, os.Mkdir(path, 0755)) + case "symlink": + if err := os.Symlink(filepath.Join(dir, filepath.FromSlash(relative)), path); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks unavailable: %v", err) + } + require.NoError(t, err) + } + } + err := migrateWithTestOperationLock(t, dir) + if kind == "equivalent bytes" { + require.NoError(t, err) + assertMigrationOriginals(t, dir, catalogs, true) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), path) + if kind == "different bytes" { + var conflict *MigrationConflictError + require.ErrorAs(t, err, &conflict) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "do not overwrite this backup", string(data)) + } else if kind == "directory" { + assert.DirExists(t, path) + } else { + info, err := os.Lstat(path) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + } + for _, catalog := range catalogs { + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(catalog.Path))) + require.NoError(t, err) + assert.Equal(t, catalog.Data, data) + } + assert.NoFileExists(t, filepath.Join(dir, migrationFileName)) + }) + } + } +} + +func TestMigrateLegacyState_InterruptedMigration(t *testing.T) { + tests := []struct { + name string + common bool + published bool + backups int + archived int + }{ + {"before publication", true, false, 0, 0}, + {"after publication before backups", true, true, 0, 0}, + {"after common backup", true, true, 1, 0}, + {"after linked backup before archive", true, true, 2, 0}, + {"after partial archival", true, true, 2, 1}, + {"after all archival before journal removal", true, true, 3, 2}, + {"absent common before publication", false, false, 0, 0}, + {"absent common after publication", false, true, 0, 0}, + {"absent common partially archived", false, true, 1, 1}, + {"absent common fully archived", false, true, 2, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + var catalogs []migrationCatalog + want := migrationTestFile() + if tt.common { + common := migrationTestStack() + catalogs = append(catalogs, writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(common))) + want.Stacks = append(want.Stacks, common) + } + for i := range 2 { + s := makeStack("main", fmt.Sprintf("linked-%d", i)) + catalogs = append(catalogs, writeMigrationTestCatalog(t, dir, fmt.Sprintf("worktrees/linked-%d/gh-stack", i), migrationTestFile(s))) + want.Stacks = append(want.Stacks, s) + } + journal, err := json.Marshal(migrationState{Version: migrationVersion, Catalogs: catalogs}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, migrationFileName), journal, 0600)) + merged, err := json.MarshalIndent(want, "", " ") + require.NoError(t, err) + if tt.published { + require.NoError(t, os.WriteFile(stackFilePath(dir), merged, 0644)) + } + for _, catalog := range catalogs[:tt.backups] { + require.NoError(t, os.WriteFile(filepath.Join(dir, filepath.FromSlash(catalog.Path))+migrationBackupSuffix, catalog.Data, catalog.Mode)) + } + archived := 0 + for _, catalog := range catalogs { + if archived == tt.archived { + break + } + if catalog.Path != stackFileName { + require.NoError(t, os.Remove(filepath.Join(dir, filepath.FromSlash(catalog.Path)))) + archived++ + } + } + has, err := HasLegacyState(dir) + require.NoError(t, err) + require.True(t, has) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + got, err := Load(dir) + require.NoError(t, err) + assert.Equal(t, want.Stacks, got.Stacks) + assert.Equal(t, want.Repository, got.Repository) + assertMigrationOriginals(t, dir, catalogs, true) + require.NoError(t, migrateWithTestOperationLock(t, dir)) + assertMigrationOriginals(t, dir, catalogs, true) + }) + } +} + +func TestMigrateLegacyState_InterruptedMigrationRefusesChanges(t *testing.T) { + for _, kind := range []string{"common modified", "legacy modified", "legacy disappeared", "new catalog", "backup conflict", "archive before publication", "recovery appeared"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + catalogs := []migrationCatalog{ + writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "common"))), + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))), + } + journal, err := json.Marshal(migrationState{Version: migrationVersion, Catalogs: catalogs}) + require.NoError(t, err) + journalPath := filepath.Join(dir, migrationFileName) + require.NoError(t, os.WriteFile(journalPath, journal, 0600)) + merged, err := json.MarshalIndent(migrationTestFile(makeStack("main", "common"), makeStack("main", "linked")), "", " ") + require.NoError(t, err) + if kind != "archive before publication" { + require.NoError(t, os.WriteFile(stackFilePath(dir), merged, 0644)) + } + legacyPath := filepath.Join(dir, "worktrees", "linked", stackFileName) + switch kind { + case "common modified": + require.NoError(t, os.WriteFile(stackFilePath(dir), []byte("externally changed common catalog"), 0644)) + case "legacy modified": + require.NoError(t, os.WriteFile(legacyPath, []byte("externally changed legacy catalog"), 0644)) + case "legacy disappeared": + require.NoError(t, os.Remove(legacyPath)) + case "new catalog": + writeMigrationTestCatalog(t, dir, "worktrees/new/gh-stack", migrationTestFile(makeStack("main", "new"))) + case "backup conflict": + require.NoError(t, os.WriteFile(legacyPath+migrationBackupSuffix, []byte("existing unrelated backup"), 0644)) + case "archive before publication": + require.NoError(t, os.WriteFile(legacyPath+migrationBackupSuffix, catalogs[1].Data, 0644)) + require.NoError(t, os.Remove(legacyPath)) + case "recovery appeared": + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack-rebase-state"), []byte("{}"), 0600)) + } + before, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + err = migrateWithTestOperationLock(t, dir) + if kind == "recovery appeared" { + var blocked *MigrationBlockedError + require.ErrorAs(t, err, &blocked) + } else { + var conflict *MigrationConflictError + require.ErrorAs(t, err, &conflict) + } + after, err := os.ReadFile(stackFilePath(dir)) + require.NoError(t, err) + assert.Equal(t, before, after) + afterJournal, err := os.ReadFile(journalPath) + require.NoError(t, err) + assert.Equal(t, journal, afterJournal, "recovery snapshots must remain available") + assert.NoFileExists(t, stackFilePath(dir)+migrationBackupSuffix) + }) + } +} + +func TestMigrateLegacyState_InvalidJournals(t *testing.T) { + for _, kind := range []string{"invalid JSON", "null", "new version", "missing version", "missing catalogs", "unsafe path", "duplicate path", "invalid mode"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + catalog := writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))) + state := migrationState{Version: migrationVersion, Catalogs: []migrationCatalog{catalog}} + switch kind { + case "new version": + state.Version++ + case "missing version": + state.Version = 0 + case "missing catalogs": + state.Catalogs = nil + case "unsafe path": + state.Catalogs[0].Path = "../gh-stack" + case "duplicate path": + state.Catalogs = append(state.Catalogs, catalog) + case "invalid mode": + state.Catalogs[0].Mode = os.ModeSymlink + } + journal, err := json.Marshal(state) + require.NoError(t, err) + if kind == "invalid JSON" { + journal = []byte("{incomplete") + } else if kind == "null" { + journal = []byte("null") + } else if kind == "missing version" { + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(journal, &fields)) + delete(fields, "version") + journal, err = json.Marshal(fields) + require.NoError(t, err) + } + journalPath := filepath.Join(dir, migrationFileName) + require.NoError(t, os.WriteFile(journalPath, journal, 0600)) + require.Error(t, migrateWithTestOperationLock(t, dir)) + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(catalog.Path))) + require.NoError(t, err) + assert.Equal(t, catalog.Data, data) + assert.FileExists(t, journalPath) + assert.NoFileExists(t, stackFilePath(dir)) + assert.NoFileExists(t, filepath.Join(dir, filepath.FromSlash(catalog.Path))+migrationBackupSuffix) + }) + } +} + +func TestMigrateLegacyState_PreservesStaleDetection(t *testing.T) { + dir := t.TempDir() + writeMigrationTestCatalog(t, dir, stackFileName, migrationTestFile(makeStack("main", "common"))) + writeMigrationTestCatalog(t, dir, "worktrees/linked/gh-stack", migrationTestFile(makeStack("main", "linked"))) + before, err := Load(dir) + require.NoError(t, err) + operation, err := LockOperation(dir) + require.NoError(t, err) + defer operation.Unlock() + require.NoError(t, MigrateLegacyState(dir)) + err = Save(dir, before) + var stale *StaleError + require.True(t, errors.As(err, &stale)) + after, err := Load(dir) + require.NoError(t, err) + after.AddStack(makeStack("main", "new")) + require.NoError(t, Save(dir, after)) + require.NoError(t, Save(dir, after), "atomic publication must refresh the load checksum") +} diff --git a/internal/worktree/context.go b/internal/worktree/context.go new file mode 100644 index 00000000..40f68c18 --- /dev/null +++ b/internal/worktree/context.go @@ -0,0 +1,353 @@ +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 := git.ForWorktree(location.Path) + id, resolveErr := locationID(ops, common) + if resolveErr == nil && (location.ID == "" || location.ID == id) { + location.ID = id + return ops, nil + } + 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 := git.ForWorktree(tree.Path) + candidateID, err := locationID(candidate, common) + if err == nil && candidateID == location.ID { + location.Path = tree.Path + return candidate, nil + } + } + } + if resolveErr == nil { + resolveErr = fmt.Errorf("worktree identity changed") + } + 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 + } + if ops.IsRebaseInProgress() || ops.IsCherryPickInProgress() { + 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 || 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 00000000..7b4b9559 --- /dev/null +++ b/internal/worktree/context_test.go @@ -0,0 +1,184 @@ +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) { + common := t.TempDir() + oldPath, newPath := filepath.Join(common, "old"), filepath.Join(common, "new") + 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: newPath, Branch: "branch"}}, nil }, + ForWorktreeFn: func(path string) git.Ops { + if path == newPath { + return owner + } + return &git.MockOps{CommonDirFn: func() (string, error) { return "", errors.New("worktree moved") }} + }, + } + 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 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 { + 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 + }, + } + } + 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 { + t.Fatal("must not use the calling worktree for an invalid record") + return 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 bb754968..34f77e86 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,13 @@ 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` automatically update affected clean worktrees; they never auto-stash or + create/remove worktrees. Mutations serialize across the clone, and paused operations require + recovery in their recorded owners. 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 fcb219a5..89ed80b0 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,10 @@ The routine command. Steps, in order: 8. **Prune** local branches for merged PRs, only when `--prune` is passed in a non-interactive environment. +Affected clean worktrees are updated automatically. Dirty/busy/unavailable owners stop unsafe +updates, and pruning skips branches occupied elsewhere. 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 +129,9 @@ 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**. +- Occupied branches are rebased in their clean owning worktrees; unoccupied branches use the + origin. Resolve/stage conflicts at the reported path. `--continue`/`--abort` may run from any + linked worktree and use the recorded owners. No auto-stash or worktree lifecycle management. ## view @@ -138,7 +151,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 +191,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 fc97b41a..f622e0cd 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,28 @@ 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. Only affected clean +owners are updated by rebase/sync; commit or stash manually when those owners are dirty. 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 +174,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.