diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3c239e4b..d564e185 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,7 +17,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai - `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/github/`: `ClientOps` interface (11 methods) for GitHub API. `MockClient` for tests. +- `internal/github/`: `ClientOps` interface (13 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`). - `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/tui/`: bubbletea views (`stackview`, `modifyview`). diff --git a/AGENTS.md b/AGENTS.md index 953438bc..98875637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,13 +35,13 @@ internal/ gitops.go # Ops interface (52 methods) mock_ops.go # MockOps. Each method has a corresponding *Fn field. github/ # github.ClientOps interface + real Client - client_interface.go # ClientOps interface (11 methods) + client_interface.go # ClientOps interface (13 methods) mock_client.go # MockClient. Uses function-pointer fields for testing. stack/ # stack file (.git/gh-stack) management, JSON schema, 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, NextNumberedName) + branch/ # branch naming (Slugify, DateSlug) modify/ # interactive stack modification state machine pr/ # PR template discovery tui/ # bubbletea/bubbles/lipgloss terminal UI @@ -109,13 +109,14 @@ 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. -- **`github.ClientOps`** (`internal/github/client_interface.go`): 11 methods for GitHub API (PRs, stacks). 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`, `TokenForHostFn`, `RepoOverride`). +- **`github.ClientOps`** (`internal/github/client_interface.go`): 13 methods for GitHub API (PRs, stacks). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. 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). - **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`. - **Staleness:** Concurrent modifications detected via `StaleError`. diff --git a/README.md b/README.md index 4e63dc4b..0943f7d4 100644 --- a/README.md +++ b/README.md @@ -76,19 +76,15 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. 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`. -Use `--numbered` with `--prefix` to enable auto-incrementing numbered branch names (`prefix/01`, `prefix/02`, …). Without `--numbered`, you'll always be prompted to provide a meaningful branch name. - Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-p, --prefix ` | Set a branch name prefix for the stack | -| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | **Examples:** @@ -104,15 +100,6 @@ gh stack init --base develop feature-auth # Adopt existing branches into a stack gh stack init feature-auth feature-api - -# Set a prefix — you'll be prompted for a branch name -gh stack init -p feat -# → prompts "Enter a name for the first branch (will be prefixed with feat/)" -# → type "auth" → creates feat/auth - -# Use numbered auto-incrementing branch names -gh stack init -p feat --numbered -# → creates feat/01 automatically ``` ### `gh stack add` @@ -125,7 +112,7 @@ gh stack add [flags] [branch] 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. -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. If the stack was created with `--numbered`, auto-generated names use numbered format (`prefix/01`, `prefix/02`); otherwise, date+slug format is used (e.g., `prefix/2025-03-24-add-login`). +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`). | Flag | Description | |------|-------------| @@ -165,21 +152,26 @@ gh stack add -m "Refactor utils" cleanup-layer ### `gh stack checkout` -Check out a stack from a pull request number, URL, or branch name. +Check out a stack by its stack number, a pull request number, a PR URL, or a branch name. ``` -gh stack checkout [ | | ] +gh stack checkout [ | | | ] ``` -When a PR number or URL is provided (e.g. `123` or `https://github.com/owner/repo/pull/123`), 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. +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. When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, closed, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** ```sh +# Check out a stack by its stack number +gh stack checkout 7 + # Check out a stack by PR number gh stack checkout 42 @@ -189,7 +181,7 @@ gh stack checkout https://github.com/owner/repo/pull/42 # Check out a stack by branch name (local only) gh stack checkout feature-auth -# Interactive — select from locally tracked stacks +# Interactive — pick from all available stacks (local and remote) gh stack checkout ``` @@ -316,15 +308,28 @@ Fetch, rebase, push, and sync PR state in a single command. gh stack sync [flags] ``` -Performs a safe, non-interactive synchronization of the entire stack: +Performs a synchronization of the entire stack: + +1. **Fetch** — fetches the latest changes from `origin`. +2. **Reconcile the remote stack** — mirrors the GitHub stack locally. When PRs have been added to the stack on GitHub (the remote is ahead of your local stack), their branches are pulled down and appended to your local stack automatically. When the local and remote stacks have genuinely diverged (for example, you added a branch locally while different PRs were added to the stack on GitHub), you are prompted to resolve (see [Diverged stacks](#diverged-stacks) below). In a non-interactive terminal a divergence aborts the sync (nothing is pushed or updated). +3. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged). +4. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. +5. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). +6. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. +7. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. This only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that). +8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. + +A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged. -1. **Fetch** — fetches the latest changes from `origin` -2. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged) -3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state and you are advised to run `gh stack rebase` to resolve conflicts interactively -4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred) -5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR -6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. Only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that) -7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically +#### Diverged stacks + +When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices: + +- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes. +- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet. +- **Cancel** — aborts the sync without pushing branches or updating any PRs. + +In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack. | Flag | Description | |------|-------------| @@ -377,6 +382,8 @@ If every PR in the stack has already been merged, that stack is complete and can In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (Ctrl+X). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with Ctrl+S. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles. +If the branches already have open PRs but no stack exists on GitHub, you will have the option to link the PRs into a stack with Ctrl+B. + In the editor, new PRs default to ready for review; flip any PR to draft with the ready ↔ draft toggle. With `--auto`, new PRs are created as drafts unless you pass `--open`. | Flag | Description | @@ -457,26 +464,33 @@ gh stack view --json ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. +Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`. ``` -gh stack unstack [flags] +gh stack unstack [] [flags] ``` -You must have an active stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking. -Deletes the stack on GitHub first, if it exists, then removes local tracking. Use `--local` to only remove from local tracking. +Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API. If the stack is also tracked locally, its local tracking is removed as well. + +Use `--local` to only remove local tracking without contacting GitHub. + +GitHub decides which pull requests can be unstacked: PRs that are queued for merge or have auto-merge enabled are left stacked. When some pull requests remain stacked, the stack is kept (and local tracking, if any, is unchanged). | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub) | **Examples:** ```sh -# Remove the stack from local tracking and GitHub +# Remove the current stack from local tracking and GitHub gh stack unstack +# Unstack a specific stack by its number +gh stack unstack 7 + # Only remove local tracking gh stack unstack --local ``` @@ -589,21 +603,21 @@ gh stack sync ## Abbreviated workflow -If you want to minimize keystrokes, use a branch prefix with `--numbered` and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated as `prefix/01`, `prefix/02`, etc. +If you want to minimize keystrokes, use the `-Am` flags to fold staging, committing, and branch creation into a single command. When you don't pass a branch name, one is auto-generated from the commit message in date+slug format (e.g., `03-24-auth_middleware`). When a branch has no commits yet (e.g., right after `init`), `add -Am` stages and commits directly on that branch instead of creating a new one. Once a branch has commits, `add -Am` creates a new branch, checks it out, and commits there. ```sh -# 1. Start a stack with a prefix and numbered branches -gh stack init -p feat --numbered -# → creates feat/01 and checks it out +# 1. Start a stack +gh stack init auth +# → creates auth and checks it out # 2. Write code for the first layer # ... write code ... # 3. Stage and commit on the current branch gh stack add -Am "Auth middleware" -# → feat/01 has no commits yet, so the commit lands here +# → auth has no commits yet, so the commit lands here # (no new branch is created) # 4. Write code for the next layer @@ -611,20 +625,20 @@ gh stack add -Am "Auth middleware" # 5. Create the next branch and commit gh stack add -Am "API routes" -# → feat/01 already has commits, so a new branch feat/02 is -# created, checked out, and the commit lands there +# → auth already has commits, so a new branch is created from the +# commit message, checked out, and the commit lands there # 6. Keep going # ... write code ... gh stack add -Am "Frontend components" -# → feat/02 already has commits, creates feat/03 and commits there +# → creates another branch and commits there # 7. Push everything and create PRs gh stack submit ``` -Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all. +Compared to the typical workflow, there's no need to name branches, run `git add`, or run `git commit` separately. Each `gh stack add -Am "..."` does it all. Pass an explicit branch name any time you want to control it: `gh stack add -Am "API routes" api-routes`. ## Terminal theme diff --git a/cmd/add.go b/cmd/add.go index b27555b6..7e50a524 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -27,8 +27,7 @@ func AddCmd(cfg *config.Config) *cobra.Command { When -m is omitted but -A or -u is used, your editor opens for the commit message. When -m is provided without an explicit branch name, -the branch name is auto-generated based on the commit message and -stack prefix.`, +the branch name is auto-generated from the commit message.`, Example: ` # Add a new named branch to the stack $ gh stack add my-feature @@ -119,55 +118,41 @@ func runAdd(cfg *config.Config, opts *addOptions, args []string) error { return nil } - // Resolve branch name + // Resolve branch name: + // explicit name -> used verbatim + // -m without a name -> auto-generated from the commit message + // neither -> prompt for a name var branchName string var explicitName string if len(args) > 0 { explicitName = args[0] } - existingBranches := s.BranchNames() - if opts.message != "" { - // Auto-naming mode - name, info := branch.ResolveBranchName(s.Prefix, opts.message, explicitName, existingBranches, s.Numbered) - if name == "" { + if explicitName != "" { + branchName = explicitName + } else if opts.message != "" { + branchName = branch.DateSlug(opts.message) + if branchName == "" { cfg.Errorf("could not generate branch name") return ErrSilent } - branchName = name - if info != "" { - cfg.Infof("%s", info) - } - } else if explicitName != "" { - branchName = applyPrefix(cfg, s.Prefix, explicitName) } else { - // No -m, no explicit name — auto-generate if using numbered - // convention, otherwise prompt for a name. - if s.Numbered && s.Prefix != "" { - branchName = branch.NextNumberedName(s.Prefix, existingBranches) - } else { - // Pre-fill the prompt with the prefix so the user can see - // (and optionally edit) the full branch name. - prefill := "" - if s.Prefix != "" { - prefill = s.Prefix + "/" - } - for { - input, err := inputWithPrefill(cfg, "Enter a name for the new branch:", prefill) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - return fmt.Errorf("could not read branch name: %w", err) - } - if input == "" { - cfg.Warningf("branch name cannot be empty, please try again") - continue + // No -m and no explicit name — prompt for one. + for { + input, err := promptInput(cfg, "Enter a name for the new branch:") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent } - branchName = input - break + return fmt.Errorf("could not read branch name: %w", err) } + if input == "" { + cfg.Warningf("branch name cannot be empty, please try again") + continue + } + branchName = input + break } } @@ -281,12 +266,3 @@ func doCommit(message string) (string, error) { } return git.CommitInteractive() } - -// applyPrefix prepends the stack prefix to a branch name if set. -func applyPrefix(cfg *config.Config, prefix, name string) string { - if prefix != "" { - name = prefix + "/" + name - cfg.Infof("Branch name prefixed: %s", name) - } - return name -} diff --git a/cmd/add_test.go b/cmd/add_test.go index 8563cc1e..9bb82d2c 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -2,6 +2,7 @@ package cmd import ( "testing" + "time" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -212,18 +213,17 @@ func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { assert.True(t, commitCalled, "expected Commit to be called on the new branch") } -func TestAdd_PrefixAppliedWithSlash(t *testing.T) { +func TestAdd_ExplicitNameUsedVerbatim(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ - Prefix: "feat", Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feat/01"}}, + Branches: []stack.BranchRef{{Branch: "b1"}}, }) var createdBranch string restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, CreateBranchFn: func(name, base string) error { createdBranch = name return nil @@ -236,22 +236,20 @@ func TestAdd_PrefixAppliedWithSlash(t *testing.T) { output := collectOutput(cfg, outR, errR) require.NotContains(t, output, "\u2717", "unexpected error") - assert.Equal(t, "feat/mybranch", createdBranch) + assert.Equal(t, "mybranch", createdBranch) } -func TestAdd_NumberedNaming(t *testing.T) { +func TestAdd_MessageAutoGeneratesDateSlug(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ - Prefix: "feat", - Numbered: true, Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feat/01"}}, + Branches: []stack.BranchRef{{Branch: "b1"}}, }) var createdBranch string restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, RevParseMultiFn: func(refs []string) ([]string, error) { return []string{"aaa", "bbb"}, nil }, @@ -271,7 +269,8 @@ func TestAdd_NumberedNaming(t *testing.T) { output := collectOutput(cfg, outR, errR) require.NotContains(t, output, "\u2717", "unexpected error") - assert.Equal(t, "feat/02", createdBranch) + today := time.Now().Format("01-02") + assert.Equal(t, today+"-next_feature", createdBranch) } func TestAdd_FullyMergedStackBlocked(t *testing.T) { @@ -322,47 +321,7 @@ func TestAdd_NothingToCommit(t *testing.T) { assert.Contains(t, output, "no changes to commit") } -func TestAdd_PromptPrefillsPrefix(t *testing.T) { - gitDir := t.TempDir() - saveStack(t, gitDir, stack.Stack{ - Prefix: "feat", - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feat/01"}}, - }) - - var createdBranch string - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "feat/01", nil }, - CreateBranchFn: func(name, base string) error { - createdBranch = name - return nil - }, - CheckoutBranchFn: func(name string) error { return nil }, - RevParseFn: func(ref string) (string, error) { return "abc", nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - - var gotPrompt, gotDefault string - cfg.InputFn = func(prompt, defaultValue string) (string, error) { - gotPrompt = prompt - gotDefault = defaultValue - return "feat/my-branch", nil - } - - err := runAdd(cfg, &addOptions{}, nil) - output := collectOutput(cfg, outR, errR) - - require.NoError(t, err) - require.NotContains(t, output, "\u2717", "unexpected error") - assert.Contains(t, gotPrompt, ":", "prompt should end with a colon") - assert.Equal(t, "feat/", gotDefault, "prompt should pre-fill prefix/") - assert.Equal(t, "feat/my-branch", createdBranch, "full input should be used as branch name") -} - -func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) { +func TestAdd_PromptForBranchName(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, @@ -384,9 +343,9 @@ func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) { cfg, outR, errR := config.NewTestConfig() - var gotDefault string - cfg.InputFn = func(prompt, defaultValue string) (string, error) { - gotDefault = defaultValue + var gotPrompt string + cfg.InputFn = func(prompt string) (string, error) { + gotPrompt = prompt return "my-branch", nil } @@ -395,22 +354,21 @@ func TestAdd_PromptNoPrefixEmptyDefault(t *testing.T) { require.NoError(t, err) require.NotContains(t, output, "\u2717", "unexpected error") - assert.Equal(t, "", gotDefault, "prompt should have empty default when no prefix") + assert.Contains(t, gotPrompt, ":", "prompt should end with a colon") assert.Equal(t, "my-branch", createdBranch, "input should be used as-is") } -func TestAdd_PromptUserModifiesPrefix(t *testing.T) { +func TestAdd_PromptInputUsedVerbatim(t *testing.T) { gitDir := t.TempDir() saveStack(t, gitDir, stack.Stack{ - Prefix: "feat", Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feat/01"}}, + Branches: []stack.BranchRef{{Branch: "b1"}}, }) var createdBranch string restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, CreateBranchFn: func(name, base string) error { createdBranch = name return nil @@ -422,8 +380,7 @@ func TestAdd_PromptUserModifiesPrefix(t *testing.T) { cfg, outR, errR := config.NewTestConfig() - cfg.InputFn = func(prompt, defaultValue string) (string, error) { - // Simulate user changing the prefix entirely + cfg.InputFn = func(prompt string) (string, error) { return "custom/other-name", nil } @@ -432,7 +389,7 @@ func TestAdd_PromptUserModifiesPrefix(t *testing.T) { require.NoError(t, err) require.NotContains(t, output, "\u2717", "unexpected error") - assert.Equal(t, "custom/other-name", createdBranch, "user-modified input should be used verbatim") + assert.Equal(t, "custom/other-name", createdBranch, "typed input should be used verbatim") } func TestAdd_FromTrunk(t *testing.T) { diff --git a/cmd/checkout.go b/cmd/checkout.go index 0ceed6ac..e1067e18 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -6,12 +6,14 @@ import ( "strconv" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/checkoutview" "github.com/spf13/cobra" ) @@ -23,9 +25,14 @@ func CheckoutCmd(cfg *config.Config) *cobra.Command { opts := &checkoutOptions{} cmd := &cobra.Command{ - Use: "checkout [ | | ]", - Short: "Checkout a stack from a PR number, PR URL, or branch name", - Long: `Check out a stack from a pull request number, PR URL, or branch name. + Use: "checkout [ | | | ]", + Short: "Checkout a stack by stack number, PR number, PR URL, or branch name", + Long: `Check out a stack by stack number, pull request number, PR URL, or branch name. + +A bare number is interpreted first as a stack number (the identifier shown in +the GitHub stack UI). If no stack has that number, it is then tried as a +locally tracked PR number, then a PR number whose stack is discovered from +GitHub, and finally a branch name. When a PR number or PR URL is provided (e.g. 123 or https://github.com/owner/repo/pull/123), the command first checks @@ -37,9 +44,14 @@ it simply switches to the branch. When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments, shows a menu of all locally available -stacks to choose from.`, - Example: ` # Check out a stack by PR number +When run without arguments, opens an interactive picker listing every +stack available to you — both the stacks tracked locally and the stacks +that exist only on GitHub — so you can search, filter, and check one out. +Fully merged stacks are omitted.`, + Example: ` # Check out a stack by its stack number + $ gh stack checkout 7 + + # Check out a stack by PR number $ gh stack checkout 42 # Check out a stack by PR URL @@ -48,7 +60,7 @@ stacks to choose from.`, # Check out a stack by branch name $ gh stack checkout feat/api-routes - # Show a menu of all locally tracked stacks + # Open the interactive picker of all available stacks (local and remote) $ gh stack checkout`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -83,18 +95,21 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { var targetBranch string if opts.target == "" { - // Interactive picker mode - s, err = interactiveStackPicker(cfg, sf) + // Interactive picker mode (local + remote stacks). + s, targetBranch, err = interactiveCheckout(cfg, sf, gitDir) if err != nil { - if !errors.Is(err, errInterrupt) { - cfg.Errorf("%s", err) + var exitErr *ExitError + if errors.As(err, &exitErr) { + // The callee already printed a message and chose an exit code. + return err } + cfg.Errorf("%s", err) return ErrSilent } if s == nil { + // No stacks available, or the user cancelled. return nil } - targetBranch = s.Branches[len(s.Branches)-1].Branch } else if prNumber, ok := parsePRURL(opts.target); ok { // Target is a PR URL — extract number and resolve like a numeric target s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) @@ -102,7 +117,7 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return err } } else if prNumber, parseErr := strconv.Atoi(opts.target); parseErr == nil && prNumber > 0 { - // Target is a pure integer — try local PR, then remote API, then branch name + // Target is a pure integer — try stack number, then PR, then branch name s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) if err != nil { return err @@ -137,29 +152,41 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { return nil } -// resolveNumericTarget handles the case where the user passes a pure integer. -// It tries, in order: -// 1. Local stack lookup by PR number -// 2. Remote API discovery (ListStacks → find → import) -// 3. Local stack lookup by branch name (for numeric branch names like "123") -func resolveNumericTarget(cfg *config.Config, sf *stack.StackFile, gitDir string, prNumber int, raw string) (*stack.Stack, string, error) { - // 1. Try local PR number lookup - if s, br := sf.FindStackByPRNumber(prNumber); s != nil && br != nil { +// resolveNumericTarget handles the case where the user passes a pure integer or +// a PR URL. The number is interpreted as, in order: +// 1. A stack number (the primary identifier) +// 2. A locally tracked PR number +// 3. A PR number whose stack is discovered from GitHub +// 4. A branch name (for numeric branch names like "123") +// +// Stack, PR, and issue numbers share a single repo-scoped numberspace, +// 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) { + // 1. Try as a stack number (the primary identifier). + if s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, number); err == nil { + return s, targetBranch, nil + } else if !errors.Is(err, errStackNumberNotFound) { + // A real error during import/reconcile (composition conflict, interrupted + // import, etc.) — surface it rather than trying other interpretations. + return nil, "", err + } + + // 2. Try a locally tracked PR number. + if s, br := sf.FindStackByPRNumber(number); s != nil && br != nil { return s, br.Branch, nil } - // 2. Try remote API - s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, prNumber) + // 3. Try a PR number whose stack is on GitHub. + s, targetBranch, err := checkoutRemoteStack(cfg, sf, gitDir, number) if err == nil { return s, targetBranch, nil } - // If the API returned a definitive "not in a stack" or a real error, - // fall through to the branch-name attempt only for "not in stack". - // For API failures (404, network errors), still fall through — - // the user might have a numeric branch name. + // For API failures or "not in a stack", still fall through to the branch-name + // attempt — the user might have a numeric branch name. remoteErr := err - // 3. Fall back to branch name lookup (handles numeric branch names) + // 4. Fall back to branch name lookup (handles numeric branch names). stacks := sf.FindAllStacksForBranch(raw) if len(stacks) > 0 { s := stacks[0] @@ -188,12 +215,13 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrAPIFailure } - // Step 1: List stacks and find one containing the target PR - remoteStack, err := findRemoteStackForPR(client, prNumber) + // Step 1: Find the stack containing the target PR via the list endpoint's + // server-side pull_request filter. + remoteStack, err := client.FindStackForPR(prNumber) if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) return nil, "", ErrAPIFailure } cfg.Errorf("failed to list stacks: %v", err) @@ -205,22 +233,20 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, } // Step 2: Fetch PR details for every PR in the remote stack - prs, err := fetchStackPRDetails(client, remoteStack.PullRequests) + prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers()) if err != nil { cfg.Errorf("failed to fetch PR details: %v", err) return nil, "", ErrAPIFailure } - // Determine trunk (base branch of the first PR) and the target branch + // Determine trunk (base branch of the first PR) and the target branch (the + // branch for the requested PR). trunk := prs[0].BaseRefName var targetBranch string - allMerged := true for _, pr := range prs { if pr.Number == prNumber { targetBranch = pr.HeadRefName - } - if !pr.Merged { - allMerged = false + break } } if targetBranch == "" { @@ -228,6 +254,66 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrAPIFailure } + return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch) +} + +// errStackNumberNotFound is returned by checkoutStackByNumber when a numeric +// argument does not resolve to a stack (no such stack, stacks unavailable, or +// any lookup failure), signalling the caller to try interpreting the argument +// as a PR number or branch name instead. +var errStackNumberNotFound = errors.New("stack number not found") + +// checkoutStackByNumber discovers a stack from GitHub by its stack number, +// reconciles it with any local state, and checks out the top-most unmerged +// branch. It returns errStackNumberNotFound when the number does not resolve to +// a stack so the caller can fall back to other interpretations. Because stack, +// PR, and issue numbers share one repo-scoped numberspace, a number that +// belongs to a PR (or nothing) simply misses here and is resolved by the +// caller's later steps. +func checkoutStackByNumber(cfg *config.Config, sf *stack.StackFile, gitDir string, stackNumber int) (*stack.Stack, string, error) { + client, err := cfg.GitHubClient() + if err != nil { + return nil, "", errStackNumberNotFound + } + + remoteStack, err := client.GetStack(stackNumber) + if err != nil || remoteStack == nil || len(remoteStack.PullRequests) == 0 { + // No such stack, stacks unavailable, or a transient failure — let the + // caller try the number as a PR number or branch name. + return nil, "", errStackNumberNotFound + } + + prs, err := fetchStackPRDetails(client, remoteStack.PRNumbers()) + if err != nil { + cfg.Errorf("failed to fetch PR details: %v", err) + return nil, "", ErrAPIFailure + } + + trunk := prs[0].BaseRefName + // Target the top-most unmerged branch, falling back to the very top. + targetBranch := prs[len(prs)-1].HeadRefName + for i := len(prs) - 1; i >= 0; i-- { + if !prs[i].Merged { + targetBranch = prs[i].HeadRefName + break + } + } + + return reconcileAndImportRemoteStack(cfg, client, sf, gitDir, remoteStack, prs, trunk, targetBranch) +} + +// reconcileAndImportRemoteStack reconciles a resolved remote stack with local +// state — adopting a matching local stack, resolving composition conflicts, or +// importing the stack from the remote — and returns the resolved local stack +// and the branch to check out. +func reconcileAndImportRemoteStack(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, gitDir string, remoteStack *github.RemoteStack, prs []*github.PullRequest, trunk, targetBranch string) (*stack.Stack, string, error) { + allMerged := true + for _, pr := range prs { + if !pr.Merged { + allMerged = false + break + } + } if allMerged { cfg.Infof("All PRs in this stack have been merged") cfg.Printf("To start a new stack, use `%s`", cfg.ColorCyan("gh stack init")) @@ -236,7 +322,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, remoteStackID := strconv.Itoa(remoteStack.ID) - // Step 3: Check if the target branch is already in a local stack + // Check if the target branch is already in a local stack. localStack := findLocalStackForRemotePRs(sf, prs) if localStack != nil { @@ -245,15 +331,18 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, syncRemotePRState(localStack, prs) // Case A: branch is in a local stack — check composition - if stackCompositionMatches(localStack, remoteStack.PullRequests) { + if stackCompositionMatches(localStack, remoteStack.PRNumbers()) { // Composition matches — checkout - if localStack.ID == "" { - localStack.ID = remoteStackID - } + // remoteStack is authoritative for both identifiers here, so + // refresh them together. Updating only one (e.g. the number while + // keeping a stale ID) breaks later ID-based discovery when the old + // remote stack was replaced by a new one holding the same PRs. + localStack.ID = remoteStackID + localStack.Number = remoteStack.Number if err := stack.Save(gitDir, sf); err != nil { return nil, "", handleSaveError(cfg, err) } - cfg.Successf("Local stack matches remote — switching to branch") + cfg.Successf("Local stack matches remote — switching to branch%s", stackLabel(remoteStack.Number)) return localStack, targetBranch, nil } @@ -274,7 +363,7 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return nil, "", ErrSilent } - s, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID) + s, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStack.Number) if err != nil { return nil, "", err } @@ -286,23 +375,6 @@ func checkoutRemoteStack(cfg *config.Config, sf *stack.StackFile, gitDir string, return s, targetBranch, nil } -// findRemoteStackForPR queries the list stacks API and returns the stack -// containing the given PR number, or nil if no stack contains it. -func findRemoteStackForPR(client github.ClientOps, prNumber int) (*github.RemoteStack, error) { - stacks, err := client.ListStacks() - if err != nil { - return nil, err - } - for i := range stacks { - for _, n := range stacks[i].PullRequests { - if n == prNumber { - return &stacks[i], nil - } - } - } - return nil, nil -} - // fetchStackPRDetails fetches PR details for each number in the stack. // Returns PRs in the same order as the input numbers. func fetchStackPRDetails(client github.ClientOps, prNumbers []int) ([]*github.PullRequest, error) { @@ -418,7 +490,7 @@ func handleCompositionConflict( return nil, ErrSilent } - s, importErr := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID) + s, importErr := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStack.Number) if importErr != nil { return nil, importErr } @@ -429,19 +501,26 @@ func handleCompositionConflict( return s, nil case 1: - // Delete remote stack, keep local - if err := client.DeleteStack(remoteStackID); err != nil { + // Unstack the remote stack, keep local + _, dissolved, err := client.Unstack(remoteStack.Number) + if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { - cfg.Warningf("Remote stack already deleted") + cfg.Warningf("Remote stack already removed") + } else if errors.As(err, &httpErr) && httpErr.StatusCode == 422 { + cfg.Errorf("Cannot unstack remote stack: %s", httpErr.Message) + return nil, ErrAPIFailure } else { - cfg.Errorf("failed to delete remote stack: %v", err) + cfg.Errorf("failed to unstack remote stack: %v", err) return nil, ErrAPIFailure } + } else if dissolved { + cfg.Successf("Remote stack removed") } else { - cfg.Successf("Remote stack deleted") + cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub") } localStack.ID = "" + localStack.Number = 0 if err := stack.Save(gitDir, sf); err != nil { return nil, handleSaveError(cfg, err) } @@ -475,6 +554,7 @@ func importRemoteStack( trunk string, prs []*github.PullRequest, remoteStackID string, + remoteStackNumber int, ) (*stack.Stack, error) { // Fetch latest refs from remote if err := git.Fetch(remote); err != nil { @@ -491,21 +571,9 @@ func importRemoteStack( // Skip merged PRs whose branches were deleted from the remote — // these no longer exist upstream and can't be created locally. for _, pr := range prs { - branch := pr.HeadRefName - if git.BranchExists(branch) { - continue - } - remoteRef := remote + "/" + branch - if err := git.CreateBranch(branch, remoteRef); err != nil { - if pr.Merged { - cfg.Infof("Skipping merged branch %s", branch) - continue - } - cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, err) - return nil, ErrSilent + if _, err := ensureLocalBranchFromRemote(cfg, remote, pr); err != nil { + return nil, err } - _ = git.SetUpstreamTracking(branch, remote) - cfg.Successf("Pulled branch %s", branch) } // Build the stack @@ -524,7 +592,8 @@ func importRemoteStack( trunkSHA, _ := git.RevParse(trunk) newStack := stack.Stack{ - ID: remoteStackID, + ID: remoteStackID, + Number: remoteStackNumber, Trunk: stack.BranchRef{ Branch: trunk, Head: trunkSHA, @@ -538,7 +607,7 @@ func importRemoteStack( // Update base SHAs from actual local refs updateBaseSHAs(s) - cfg.Successf("Imported stack with %d branches from GitHub", len(prs)) + cfg.Successf("Imported stack with %d branches from GitHub%s", len(prs), stackLabel(remoteStackNumber)) return s, nil } @@ -563,40 +632,101 @@ func syncRemotePRState(s *stack.Stack, prs []*github.PullRequest) { } } -// interactiveStackPicker shows a menu of all locally tracked stacks and returns -// the one the user selects. Returns nil, nil if the user has no stacks. -func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) { +// interactiveCheckout opens the interactive stack picker, which lists every +// stack available to the user (locally tracked and remote-only, reconciled and +// with fully-merged stacks filtered out), and resolves the user's choice to a +// stack and the branch to check out. It returns (nil, "", nil) when the user +// cancels or has no stacks. Remote-only selections are cloned down through the +// same path as `gh stack checkout `. +func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string) (*stack.Stack, string, error) { if !cfg.IsInteractive() { - return nil, fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack") + return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack") } - if len(sf.Stacks) == 0 { - cfg.Infof("No locally tracked stacks found") - cfg.Printf("Create a stack with `%s` or check out a remote stack with `%s`", + rows := gatherCheckoutRows(cfg, sf) + if len(rows) == 0 { + cfg.Infof("No stacks available to check out") + cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`", cfg.ColorCyan("gh stack init"), - cfg.ColorCyan("gh stack checkout 123")) - return nil, nil + cfg.ColorCyan("gh stack checkout ")) + return nil, "", nil } - options := make([]string, len(sf.Stacks)) - for i := range sf.Stacks { - options[i] = sf.Stacks[i].DisplayChain() + selected, ok, err := launchCheckoutPicker(rows) + if err != nil { + return nil, "", err + } + if !ok { + // The user dismissed the picker without selecting. + return nil, "", nil } - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - selected, err := p.Select( - "Select a stack to check out (showing locally tracked stacks only)", - "", - options, - ) + return resolveCheckoutSelection(cfg, sf, gitDir, selected) +} + +// gatherCheckoutRows fetches the remote stacks (best-effort) and reconciles them +// with the local stacks into the picker's rows. Any GitHub failure (stacks not +// enabled for the repo, no auth, network error) gracefully degrades to a +// local-only list. +func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) []checkoutview.StackRow { + var remote []github.RemoteStack + if client, err := cfg.GitHubClient(); err == nil { + if stacks, err := client.ListStacks(); err == nil { + remote = stacks + } + } + return checkoutview.BuildRows(sf.Stacks, remote) +} + +// resolveCheckoutSelection resolves a picker selection to a local stack and the +// branch to check out. Locally available stacks are used directly; remote-only +// stacks are cloned down by stack number through the same import/reconcile flow +// as `gh stack checkout `. +func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir string, selected checkoutview.StackRow) (*stack.Stack, string, error) { + if selected.Type == checkoutview.TypeLocal && selected.LocalStack != nil { + return selected.LocalStack, topUnmergedBranch(selected.LocalStack), nil + } + + s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, selected.Number) if err != nil { - if isInterruptError(err) { - clearSelectPrompt(cfg, len(options)) - printInterrupt(cfg) - return nil, errInterrupt + if errors.Is(err, errStackNumberNotFound) { + cfg.Errorf("stack #%d could not be loaded from GitHub", selected.Number) + return nil, "", ErrAPIFailure } - return nil, fmt.Errorf("stack selection: %w", err) + return nil, "", err } + return s, targetBranch, nil +} - return &sf.Stacks[selected], nil +// launchCheckoutPicker runs the Bubble Tea stack picker and returns the selected +// row (and whether one was chosen). It renders inline (no alt-screen) so the +// picker occupies only a few lines and leaves the surrounding terminal output +// intact. Mouse motion is intentionally not enabled so the search field never +// receives stray mouse bytes. +func launchCheckoutPicker(rows []checkoutview.StackRow) (checkoutview.StackRow, bool, error) { + p := tea.NewProgram(checkoutview.New(rows)) + finalModel, err := p.Run() + if err != nil { + return checkoutview.StackRow{}, false, fmt.Errorf("running stack picker: %w", err) + } + m, ok := finalModel.(checkoutview.Model) + if !ok { + return checkoutview.StackRow{}, false, nil + } + row, selected := m.Result() + return row, selected, nil +} + +// topUnmergedBranch returns the top-most branch of a local stack that has not +// been merged, falling back to the very top branch when every branch is merged. +func topUnmergedBranch(s *stack.Stack) string { + if len(s.Branches) == 0 { + return "" + } + for i := len(s.Branches) - 1; i >= 0; i-- { + if !s.Branches[i].IsMerged() { + return s.Branches[i].Branch + } + } + return s.Branches[len(s.Branches)-1].Branch } diff --git a/cmd/checkout_picker_test.go b/cmd/checkout_picker_test.go new file mode 100644 index 00000000..a97e0b55 --- /dev/null +++ b/cmd/checkout_picker_test.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/checkoutview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInteractiveCheckout_NonInteractive(t *testing.T) { + cfg, _, _ := config.NewTestConfig() // ForceInteractive defaults to false + sf := &stack.StackFile{SchemaVersion: 1} + + _, _, err := interactiveCheckout(cfg, sf, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no target specified") +} + +func TestGatherCheckoutRows_FallbackToLocalOnListError(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "stacks not enabled"} + }, + } + + sf := &stack.StackFile{Stacks: []stack.Stack{{ + Number: 5, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-a", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "feat-b"}, + }, + }}} + + rows := gatherCheckoutRows(cfg, sf) + require.Len(t, rows, 1, "falls back to a local-only list when ListStacks fails") + assert.Equal(t, checkoutview.TypeLocal, rows[0].Type) + assert.Equal(t, 5, rows[0].Number) +} + +func TestGatherCheckoutRows_IncludesRemoteOnlyStacks(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ + ID: 200, + Number: 55, + Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 7, State: "open", Head: github.RemoteStackPRHead{Ref: "r1"}}, + {Number: 8, State: "open", Head: github.RemoteStackPRHead{Ref: "r2"}}, + }, + }}, nil + }, + } + + sf := &stack.StackFile{Stacks: []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "local-a"}}, + }}} + + rows := gatherCheckoutRows(cfg, sf) + require.Len(t, rows, 2, "local and remote-only stacks are both listed") + + var haveLocal, haveRemote bool + for _, r := range rows { + switch r.Type { + case checkoutview.TypeLocal: + haveLocal = true + case checkoutview.TypeRemote: + haveRemote = true + assert.Equal(t, 55, r.Number) + } + } + assert.True(t, haveLocal, "local stack present") + assert.True(t, haveRemote, "remote-only stack present") +} + +func TestResolveCheckoutSelection_Local(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + localStack := &stack.Stack{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + } + sel := checkoutview.StackRow{Type: checkoutview.TypeLocal, Number: 3, LocalStack: localStack} + + s, branch, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel) + require.NoError(t, err) + assert.Same(t, localStack, s) + assert.Equal(t, "b", branch, "checks out the top unmerged branch") +} + +func TestResolveCheckoutSelection_RemoteRoutesToClone(t *testing.T) { + gitDir := t.TempDir() + var createdBranches []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { return nil }, + RevParseFn: func(ref string) (string, error) { return "abc123", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + sf, err := stack.Load(gitDir) + require.NoError(t, err) + + var gotStackNumber int + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + gotStackNumber = n + return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2"}, + } + return prs[number], nil + }, + } + + sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7} + s, branch, err := resolveCheckoutSelection(cfg, sf, gitDir, sel) + require.NoError(t, err) + assert.Equal(t, 7, gotStackNumber, "remote selection is cloned by stack number") + assert.Equal(t, "feat-3", branch, "targets the top-most branch") + require.NotNil(t, s) + assert.Equal(t, "42", s.ID) + assert.Equal(t, 7, s.Number) +} + +func TestResolveCheckoutSelection_RemoteLoadFailure(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return nil, errors.New("boom") + }, + } + + sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7} + _, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel) + + var exitErr *ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, ErrAPIFailure, err) +} + +func TestTopUnmergedBranch(t *testing.T) { + tests := []struct { + name string + branches []stack.BranchRef + expect string + }{ + {"empty", nil, ""}, + { + name: "some unmerged", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + {Branch: "c"}, + }, + expect: "c", + }, + { + name: "all merged falls back to top", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + expect: "b", + }, + { + name: "merged on top of unmerged", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + expect: "a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stack.Stack{Branches: tt.branches} + assert.Equal(t, tt.expect, topUnmergedBranch(s)) + }) + } +} diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go index b6e2113d..1cfb808a 100644 --- a/cmd/checkout_test.go +++ b/cmd/checkout_test.go @@ -158,9 +158,9 @@ func TestCheckout_NumericTarget_StacksNotAvailable(t *testing.T) { require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) cfg, outR, errR := config.NewTestConfig() - setTestTokenForHost(cfg, "gho_test_oauth_token") + setTestRepo(cfg) cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { + FindStackForPRFn: func(int) (*github.RemoteStack, error) { return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} }, } @@ -184,10 +184,8 @@ func TestCheckout_NumericTarget_PRNotInStack(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 1, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return nil, nil // PR 99 is not part of any stack }, } @@ -243,10 +241,8 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 42, PullRequests: []int{10, 11, 12}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11, 12}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -288,6 +284,123 @@ func TestCheckout_NumericTarget_NewStack(t *testing.T) { assert.Equal(t, 12, sf.Stacks[0].Branches[2].PullRequest.Number) } +func TestCheckout_ByStackNumber(t *testing.T) { + gitDir := t.TempDir() + var checkedOut string + var createdBranches []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { return "abc123", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + var gotStackNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + gotStackNumber = n + if n == 7 { + return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil + } + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main", URL: "https://github.com/o/r/pull/10"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1", URL: "https://github.com/o/r/pull/11"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"}, + } + return prs[number], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "7"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, gotStackNumber, "should look the stack up by its number") + // The top-most (last) branch of the stack is checked out. + assert.Equal(t, "feat-3", checkedOut) + assert.Contains(t, output, "Imported stack with 3 branches") + + // Verify the stack was imported with both its internal id and stack number. + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "42", sf.Stacks[0].ID) + assert.Equal(t, 7, sf.Stacks[0].Number) +} + +func TestCheckout_ByStackNumber_404FallsThroughToPR(t *testing.T) { + // A 404 from GetStack means no such stack, so the number is tried as a PR. + gitDir := t.TempDir() + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + SetUpstreamTrackingFn: func(string, string) error { return nil }, + RevParseFn: func(string) (string, error) { return "abc123", nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{}) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{11, 12}}, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "main", URL: "https://github.com/o/r/pull/11"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2", URL: "https://github.com/o/r/pull/12"}, + } + return prs[n], nil + }, + } + + err := runCheckout(cfg, &checkoutOptions{target: "11"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "feat-2", checkedOut, "the number should resolve as PR #11 after a stack 404") + assert.Contains(t, output, "Imported stack with 2 branches") +} + func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) { gitDir := t.TempDir() var checkedOut string @@ -331,10 +444,8 @@ func TestCheckout_NumericTarget_BranchExistsNoStack(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 99, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -445,11 +556,9 @@ func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) { apiCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { + FindStackForPRFn: func(int) (*github.RemoteStack, error) { apiCalled = true - return []github.RemoteStack{ - {ID: 99, PullRequests: []int{10, 11}}, - }, nil + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -464,7 +573,7 @@ func TestCheckout_NumericTarget_LocalMiss_RemoteMatch(t *testing.T) { _ = collectOutput(cfg, outR, errR) require.NoError(t, err) - assert.True(t, apiCalled, "should have called ListStacks API when local miss") + assert.True(t, apiCalled, "should have queried the remote stack API when local miss") assert.Equal(t, "feat-2", checkedOut) } @@ -493,8 +602,8 @@ func TestCheckout_NumericTarget_FallbackToBranchName(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{}, nil // no remote stacks + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return nil, nil // no remote stack contains this PR }, } @@ -527,11 +636,9 @@ func TestCheckout_NumericTarget_CompositionMismatch_NonInteractive(t *testing.T) cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { + FindStackForPRFn: func(int) (*github.RemoteStack, error) { // Remote stack has PRs 10, 11, 12 (extra PR added) - return []github.RemoteStack{ - {ID: 42, PullRequests: []int{10, 11, 12}}, - }, nil + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11, 12}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -590,10 +697,8 @@ func TestCheckout_NumericTarget_ClosedMergedPR(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 50, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 50, Number: 50, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -662,10 +767,8 @@ func TestCheckout_NumericTarget_MergedBranchDeletedFromRemote(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 60, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 60, Number: 60, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -698,10 +801,8 @@ func TestCheckout_NumericTarget_AllPRsMerged(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 70, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 70, Number: 70, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { prs := map[int]*github.PullRequest{ @@ -732,7 +833,7 @@ func TestCheckout_NumericTarget_APIError(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { + FindStackForPRFn: func(int) (*github.RemoteStack, error) { return nil, fmt.Errorf("network error") }, } @@ -796,8 +897,8 @@ func TestCheckout_NumericTarget_EmptyStacks(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{}, nil // no stacks at all + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return nil, nil // no stacks at all }, } @@ -903,34 +1004,6 @@ func TestStackCompositionMatches(t *testing.T) { } } -func TestFindRemoteStackForPR(t *testing.T) { - mock := &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 1, PullRequests: []int{10, 11}}, - {ID: 2, PullRequests: []int{20, 21, 22}}, - }, nil - }, - } - - // Found in first stack - rs, err := findRemoteStackForPR(mock, 11) - require.NoError(t, err) - require.NotNil(t, rs) - assert.Equal(t, 1, rs.ID) - - // Found in second stack - rs, err = findRemoteStackForPR(mock, 21) - require.NoError(t, err) - require.NotNil(t, rs) - assert.Equal(t, 2, rs.ID) - - // Not found - rs, err = findRemoteStackForPR(mock, 99) - require.NoError(t, err) - assert.Nil(t, rs) -} - func TestCheckout_ByPRURL_Local(t *testing.T) { // When a PR URL resolves to a locally tracked stack, no API call needed gitDir := t.TempDir() @@ -973,14 +1046,14 @@ func TestCheckout_ByPRURL_Remote(t *testing.T) { } restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - BranchExistsFn: func(name string) bool { return name == "main" }, - FetchFn: func(string) error { return nil }, - CreateBranchFn: func(string, string) error { return nil }, + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, SetUpstreamTrackingFn: func(string, string) error { return nil }, - RevParseFn: func(string) (string, error) { return "abc123", nil }, - ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + RevParseFn: func(string) (string, error) { return "abc123", nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, CheckoutBranchFn: func(name string) error { checkedOut = name return nil @@ -993,10 +1066,8 @@ func TestCheckout_ByPRURL_Remote(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{ - {ID: 1, PullRequests: []int{10, 11}}, - }, nil + FindStackForPRFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 1, Number: 1, PullRequests: []int{10, 11}}, nil }, FindPRByNumberFn: func(n int) (*github.PullRequest, error) { if pr, ok := prDB[n]; ok { diff --git a/cmd/init.go b/cmd/init.go index 5e06d8fe..847be2a6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -6,7 +6,6 @@ import ( "strings" "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/stack" @@ -16,8 +15,6 @@ import ( type initOptions struct { branches []string base string - prefix string - numbered bool adopt bool // deprecated, kept for backward compat } @@ -44,9 +41,6 @@ Use --base to specify a different trunk branch.`, # Adopt existing branches into a stack (bottom to top) $ gh stack init feat/auth feat/api feat/ui - # Create a stack with auto-numbered branches (feat/01, feat/02, etc.) - $ gh stack init --prefix feat --numbered - # Specify a different trunk branch $ gh stack init --base develop my-feature`, RunE: func(cmd *cobra.Command, args []string) error { @@ -56,8 +50,6 @@ Use --base to specify a different trunk branch.`, } cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") - cmd.Flags().StringVarP(&opts.prefix, "prefix", "p", "", "Branch name prefix for the stack") - cmd.Flags().BoolVarP(&opts.numbered, "numbered", "n", false, "Use auto-incrementing numbered branch names (requires --prefix)") cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Deprecated: existing branches are now adopted automatically") _ = cmd.Flags().MarkHidden("adopt") @@ -123,20 +115,6 @@ func runInit(cfg *config.Config, opts *initOptions) error { cfg.ColorCyan("gh stack init ...")) } - // --numbered requires a prefix (either from flag or interactive input). - if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() { - cfg.Errorf("--numbered requires --prefix") - return ErrInvalidArgs - } - - // Validate explicit --prefix before branch creation. - if opts.prefix != "" { - if err := git.ValidateRefName(opts.prefix); err != nil { - cfg.Errorf("invalid prefix %q: must be a valid git ref component", opts.prefix) - return ErrInvalidArgs - } - } - // --- Branch collection --- var branches []string @@ -149,39 +127,6 @@ func runInit(cfg *config.Config, opts *initOptions) error { return err } - } else if opts.numbered { - // === NUMBERED PATH (unchanged) === - if opts.prefix == "" && cfg.IsInteractive() { - prefixInput, err := inputWithPrefill(cfg, "Enter a branch prefix (required for --numbered):", "") - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return ErrSilent - } - cfg.Errorf("failed to read prefix: %s", err) - return ErrSilent - } - opts.prefix = strings.TrimSpace(prefixInput) - if opts.prefix == "" { - cfg.Errorf("--numbered requires a prefix") - return ErrInvalidArgs - } - } - branchName := branch.NextNumberedName(opts.prefix, nil) - if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { - cfg.Errorf("branch %q already exists in a stack", branchName) - return ErrInvalidArgs - } - if git.BranchExists(branchName) { - adopted[branchName] = true - } else { - if err := git.CreateBranch(branchName, trunk); err != nil { - cfg.Errorf("creating branch %s: %s", branchName, err) - return ErrSilent - } - } - branches = []string{branchName} - } else { // === INTERACTIVE PATH === if !cfg.IsInteractive() { @@ -190,7 +135,7 @@ func runInit(cfg *config.Config, opts *initOptions) error { } var interactiveAdopted bool - branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, currentBranch, opts) + branches, interactiveAdopted, err = runInteractiveInit(cfg, sf, trunk, currentBranch) if err != nil { return err } @@ -213,8 +158,6 @@ func runInit(cfg *config.Config, opts *initOptions) error { } newStack := stack.Stack{ - Prefix: opts.prefix, - Numbered: opts.numbered, Trunk: stack.BranchRef{ Branch: trunk, Head: trunkSHA, @@ -279,11 +222,6 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi resolved := make([]branchInfo, 0, len(opts.branches)) for _, b := range opts.branches { - // Apply explicit --prefix (not detected prefix) - if opts.prefix != "" { - b = opts.prefix + "/" + b - } - // Validate ref name before checking existence or creating if err := git.ValidateRefName(b); err != nil { cfg.Errorf("invalid branch name %q: must be a valid git ref", b) @@ -322,10 +260,10 @@ func resolveArgBranches(cfg *config.Config, opts *initOptions, sf *stack.StackFi } // runInteractiveInit runs the interactive init flow: prints hint about -// multi-branch args, offers current branch or new branch, then runs -// prefix detection. Returns the branches and whether the branch was adopted -// (already existed). -func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentBranch string, opts *initOptions) ([]string, bool, error) { +// multi-branch args, then offers to use the current branch or create a new +// one. Returns the branches and whether the branch was adopted (already +// existed). +func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentBranch string) ([]string, bool, error) { p := prompter.New(cfg.In, cfg.Out, cfg.Err) cfg.Printf("Initializing a stack from %s.", trunk) @@ -369,7 +307,7 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB branchName = currentBranch } else { // Create a new branch — fall through to input prompt - name, err := promptBranchName(cfg, opts.prefix) + name, err := promptBranchName(cfg) if err != nil { return nil, false, err } @@ -377,7 +315,7 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB } } else { // On trunk or detached HEAD — prompt for name directly - name, err := promptBranchName(cfg, opts.prefix) + name, err := promptBranchName(cfg) if err != nil { return nil, false, err } @@ -399,39 +337,12 @@ func runInteractiveInit(cfg *config.Config, sf *stack.StackFile, trunk, currentB } } - // Prefix detection (interactive path, no --prefix flag) - if opts.prefix == "" { - if lastSlash := strings.LastIndex(branchName, "/"); lastSlash > 0 { - detected := branchName[:lastSlash] - usePrefix, err := p.Confirm( - fmt.Sprintf("Use %q as a prefix for new branches in this stack?", detected+"/"), - true, - ) - if err != nil { - if isInterruptError(err) { - printInterrupt(cfg) - return nil, false, ErrSilent - } - // Not fatal — just skip prefix - } else if usePrefix { - opts.prefix = detected - } - } - } - return []string{branchName}, wasAdopted, nil } -// promptBranchName prompts the user for a branch name, pre-filling the -// prefix in the input when set so the user can see and edit the full name. -func promptBranchName(cfg *config.Config, prefix string) (string, error) { - prefill := "" - prompt := "What's the name of the first branch:" - if prefix != "" { - prompt = "Enter a name for the first branch:" - prefill = prefix + "/" - } - branchName, err := inputWithPrefill(cfg, prompt, prefill) +// promptBranchName prompts the user for the name of the first branch. +func promptBranchName(cfg *config.Config) (string, error) { + branchName, err := promptInput(cfg, "What's the name of the first branch:") if err != nil { if isInterruptError(err) { printInterrupt(cfg) diff --git a/cmd/init_test.go b/cmd/init_test.go index a7ef5c89..687932ed 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -94,25 +94,7 @@ func TestInit_AdoptExistingBranches(t *testing.T) { assert.Equal(t, []string{"b1", "b2", "b3"}, names) } -func TestInit_PrefixStoredInStack(t *testing.T) { - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - runInit(cfg, &initOptions{branches: []string{"myBranch"}, prefix: "feat"}) - collectOutput(cfg, outR, errR) - - sf, err := stack.Load(gitDir) - require.NoError(t, err, "loading stack") - assert.Equal(t, "feat", sf.Stacks[0].Prefix) -} - -func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) { +func TestInit_ExplicitBranchNamesUsedVerbatim(t *testing.T) { gitDir := t.TempDir() var created []string restore := git.SetOps(&git.MockOps{ @@ -127,43 +109,16 @@ func TestInit_PrefixAppliedToExplicitBranches(t *testing.T) { defer restore() cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "feat"}) + err := runInit(cfg, &initOptions{branches: []string{"feat/a", "feat/b"}}) output := collectOutput(cfg, outR, errR) require.NoError(t, err, "runInit should succeed") require.NotContains(t, output, "\u2717", "unexpected error") - assert.Equal(t, []string{"feat/b1", "feat/b2"}, created, "branches should be created with prefix") + assert.Equal(t, []string{"feat/a", "feat/b"}, created, "branches should be created verbatim") sf, err := stack.Load(gitDir) require.NoError(t, err, "loading stack") - names := sf.Stacks[0].BranchNames() - assert.Equal(t, []string{"feat/b1", "feat/b2"}, names, "stack should store prefixed branch names") -} - -func TestInit_InvalidPrefixRejectedBeforeBranchCreation(t *testing.T) { - gitDir := t.TempDir() - var created []string - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - ValidateRefNameFn: func(name string) error { - return fmt.Errorf("invalid ref name: %s", name) - }, - CreateBranchFn: func(name, base string) error { - created = append(created, name) - return nil - }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"mybranch"}, prefix: "bad..prefix"}) - output := collectOutput(cfg, outR, errR) - - assert.ErrorIs(t, err, ErrInvalidArgs, "should reject invalid prefix") - assert.Contains(t, output, "invalid prefix") - assert.Empty(t, created, "no branches should be created when prefix is invalid") + assert.Equal(t, []string{"feat/a", "feat/b"}, sf.Stacks[0].BranchNames(), "stack should store branch names verbatim") } func TestInit_AdoptFlagShowsDeprecationWarning(t *testing.T) { @@ -464,110 +419,6 @@ func TestInit_ImplicitAdopt_Mixed(t *testing.T) { assert.Equal(t, []string{"existing1", "new1", "existing2"}, sf.Stacks[0].BranchNames()) } -func TestInit_PrefixDetection_ArgsCommonPrefix(t *testing.T) { - // Explicit branch names with a common prefix should NOT auto-detect - // a prefix — the slash is part of the branch name, not a convention. - // Users who want a prefix should use --prefix. - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - CreateBranchFn: func(name, base string) error { return nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"feat/a", "feat/b", "feat/c"}}) - collectOutput(cfg, outR, errR) - - require.NoError(t, err) - sf, _ := stack.Load(gitDir) - assert.Equal(t, "", sf.Stacks[0].Prefix) -} - -func TestInit_PrefixDetection_ArgsMixedPrefix(t *testing.T) { - // Scenario 10: args mixed prefixes → no prefix - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - CreateBranchFn: func(name, base string) error { return nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"feat/a", "bug/b"}}) - collectOutput(cfg, outR, errR) - - require.NoError(t, err) - sf, _ := stack.Load(gitDir) - assert.Equal(t, "", sf.Stacks[0].Prefix) -} - -func TestInit_PrefixDetection_ArgsNoSlash(t *testing.T) { - // No slashes → no prefix - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - CreateBranchFn: func(name, base string) error { return nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"auth", "api", "ui"}}) - collectOutput(cfg, outR, errR) - - require.NoError(t, err) - sf, _ := stack.Load(gitDir) - assert.Equal(t, "", sf.Stacks[0].Prefix) -} - -func TestInit_PrefixDetection_NestedPrefix(t *testing.T) { - // Explicit branch names with nested slashes should NOT auto-detect - // a prefix — the user typed the full branch name deliberately. - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - CreateBranchFn: func(name, base string) error { return nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"sameen/feat/a", "sameen/feat/b"}}) - collectOutput(cfg, outR, errR) - - require.NoError(t, err) - sf, _ := stack.Load(gitDir) - assert.Equal(t, "", sf.Stacks[0].Prefix) -} - -func TestInit_ExplicitPrefixSkipsDetection(t *testing.T) { - // Scenario 14: --prefix with args → explicit wins - gitDir := t.TempDir() - restore := git.SetOps(&git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - DefaultBranchFn: func() (string, error) { return "main", nil }, - CurrentBranchFn: func() (string, error) { return "main", nil }, - CreateBranchFn: func(name, base string) error { return nil }, - }) - defer restore() - - cfg, outR, errR := config.NewTestConfig() - err := runInit(cfg, &initOptions{branches: []string{"b1", "b2"}, prefix: "foo"}) - collectOutput(cfg, outR, errR) - - require.NoError(t, err) - sf, _ := stack.Load(gitDir) - assert.Equal(t, "foo", sf.Stacks[0].Prefix) - assert.Equal(t, []string{"foo/b1", "foo/b2"}, sf.Stacks[0].BranchNames()) -} - func TestInit_WhatsNext_Fresh(t *testing.T) { // Scenario 17: fresh single-branch → fresh format gitDir := t.TempDir() @@ -730,8 +581,6 @@ func TestInit_Interactive_OnFeatureBranch_UseCurrent(t *testing.T) { sf, _ := stack.Load(gitDir) require.Len(t, sf.Stacks, 1) assert.Equal(t, []string{"feat/auth"}, sf.Stacks[0].BranchNames()) - // Prefix detection Y/n prompt fails gracefully without a TTY, - // so prefix is not set. The args-path prefix detection is tested separately. } func TestInit_TwoPassValidation_NoBranchCreatedOnError(t *testing.T) { diff --git a/cmd/link.go b/cmd/link.go index e74e5450..86905bd0 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -14,16 +14,17 @@ import ( ) type linkOptions struct { - base string - open bool - remote string + base string + open bool + remote string + baseChanged bool } func LinkCmd(cfg *config.Config) *cobra.Command { opts := &linkOptions{} cmd := &cobra.Command{ - Use: "link [...]", + Use: "link [...]", Short: "Link PRs into a stack on GitHub without local tracking", Long: `Create or update a stack on GitHub from branch names, PR numbers, or PR URLs. @@ -46,7 +47,15 @@ automatically with the correct base branch chaining. If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include -the new PRs (existing PRs are never removed).`, +the new PRs (existing PRs are never removed). + +As a shortcut for growing an existing stack, pass a stack number as the +first argument (the number shown in the GitHub stack UI). The remaining +arguments are appended to the top of that stack, so you don't have to +re-list its current PRs. Arguments already in the stack are skipped; +arguments that belong to a different stack are rejected. Because stack and +PR numbers never overlap, a numeric first argument is treated as a stack +only when it matches an existing stack.`, Example: ` # Link branches into a stack (bottom to top) $ gh stack link auth-layer api-routes ui-components @@ -56,10 +65,14 @@ the new PRs (existing PRs are never removed).`, # Link existing PRs by URL $ gh stack link https://github.com/owner/repo/pull/41 https://github.com/owner/repo/pull/42 + # Add PRs to the top of an existing stack (7 is a stack number) + $ gh stack link 7 48 ui-polish + # Specify a custom base branch for stack $ gh stack link --base develop auth-layer api-routes`, Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + opts.baseChanged = cmd.Flags().Changed("base") return runLink(cfg, opts, args) }, } @@ -92,51 +105,111 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error { return ErrAPIFailure } - // Phase 1: Push branch args to the remote so PRs can be found/created - if err := pushBranchArgs(cfg, opts, args); err != nil { + // Fetch existing stacks up front. They are needed to detect whether the + // first argument names an existing stack (add mode) and, in the + // create/update path, to find the stack the PRs already belong to. + cfg.Printf("Checking existing stacks...") + stacks, err := listStacksSafe(cfg, client) + if err != nil { return err } - // Phase 2: Find existing PRs for all args (don't create yet) - cfg.Printf("Looking up PRs for %d %s...", len(args), plural(len(args), "branch", "branches")) - found, err := findExistingPRs(cfg, client, args) - if err != nil { + // Detect "add mode": when the first argument is a bare stack number that + // matches an existing stack, the remaining arguments are appended to the + // top of that stack. Stack, PR, and issue numbers share one repo-scoped + // numberspace, so a number that names a stack never also names a PR. + targetStack, prArgs := detectAddMode(args, stacks) + + // Phase 1: Push branch args to the remote so PRs can be found/created. + if err := pushBranchArgs(cfg, opts, prArgs); err != nil { return err } - // Phase 2b: Validate that all found PRs are eligible to be added to a stack. - // Only open/draft PRs without auto-merge enabled are allowed. - if err := validatePREligibility(cfg, found); err != nil { + // Phase 2: Find existing PRs for all PR args (don't create yet). + cfg.Printf("Looking up PRs for %d %s...", len(prArgs), plural(len(prArgs), "branch", "branches")) + found, err := findExistingPRs(cfg, client, prArgs) + if err != nil { return err } - // Phase 3: Pre-validate the stack — check that adding these PRs won't - // conflict with existing stacks before creating any new PRs. - // Also fetches stacks for reuse in the upsert phase. + // Look up the repository's PR template (best-effort; skip if not in a repo). + var templateContent string + if repoRoot, tlErr := git.RootDir(); tlErr == nil { + templateContent = pr.FindTemplate(repoRoot) + } + + // Add mode: append the PR args to the top of the named stack. + if targetStack != nil { + return runLinkAdd(cfg, client, opts, targetStack, stacks, prArgs, found, templateContent) + } + + // Create/update mode: create a new stack or additively update the stack + // the PRs already belong to. + return runLinkCreateOrUpdate(cfg, client, opts, stacks, prArgs, found, templateContent) +} + +// detectAddMode reports whether the first argument names an existing stack, +// enabling "add mode" in which the remaining arguments are appended to the top +// of that stack. It returns the matched stack (or nil) and the PR arguments to +// resolve — args[1:] in add mode, or all args otherwise. +// +// Only a bare positive integer that isn't also a local branch name can name a +// stack. Stack, PR, and issue numbers share one repo-scoped numberspace (so a +// number never doubles as a PR), but branch names don't — a branch literally +// named like a stack number is kept as a branch. +func detectAddMode(args []string, stacks []github.RemoteStack) (*github.RemoteStack, []string) { + if len(args) < 2 { + return nil, args + } + n, err := strconv.Atoi(args[0]) + if err != nil || n <= 0 || git.BranchExists(args[0]) { + return nil, args + } + for i := range stacks { + if stacks[i].Number == n { + return &stacks[i], args[1:] + } + } + return nil, args +} + +// runLinkCreateOrUpdate creates a new stack from the resolved PR args, or +// additively updates the single stack the PRs already belong to. This is the +// original link behavior, where every PR in the stack must be listed. +func runLinkCreateOrUpdate(cfg *config.Config, client github.ClientOps, opts *linkOptions, stacks []github.RemoteStack, prArgs []string, found []*resolvedArg, templateContent string) error { knownPRNumbers := make([]int, 0, len(found)) for _, r := range found { if r != nil { knownPRNumbers = append(knownPRNumbers, r.prNumber) } } - cfg.Printf("Checking existing stacks...") - stacks, err := listStacksSafe(cfg, client) + + // Determine the stack these PRs already belong to (if any). PRs that are + // already members of this stack are exempt from the eligibility checks + // below, since they are not being added — they are already present. + targetStack, err := findMatchingStack(stacks, knownPRNumbers) if err != nil { + cfg.Errorf("%s", err) + return ErrDisambiguate + } + + // Validate that all found PRs are eligible to be added to a stack. Only + // open/draft PRs without auto-merge enabled are allowed, except for PRs + // already in the target stack. + if err := validatePREligibility(cfg, found, targetStack); err != nil { return err } - if len(knownPRNumbers) > 0 { - if err := prevalidateStack(cfg, stacks, knownPRNumbers); err != nil { + + // Pre-validate the stack — check that adding these PRs won't drop existing + // PRs from the target stack before creating any new PRs, so we can fail + // early without leaving orphaned PRs. + if targetStack != nil { + if err := prevalidateStack(cfg, targetStack, knownPRNumbers); err != nil { return err } } - // Look up the repository's PR template (best-effort; skip if not in a repo). - var templateContent string - if repoRoot, tlErr := git.RootDir(); tlErr == nil { - templateContent = pr.FindTemplate(repoRoot) - } - - // Phase 4: Create PRs for branches that don't have one yet + // Create PRs for branches that don't have one yet. needsCreation := 0 for _, r := range found { if r == nil { @@ -146,15 +219,15 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error { if needsCreation > 0 { cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs")) } - resolved, err := createMissingPRs(cfg, client, opts, args, found, templateContent) + resolved, err := createMissingPRs(cfg, client, opts, prArgs, found, templateContent, opts.base) if err != nil { return err } - // Phase 5: Fix base branches for existing PRs with wrong bases - fixBaseBranches(cfg, client, opts, resolved) + // Fix base branches for existing PRs with wrong bases. + fixBaseBranches(cfg, client, opts, resolved, opts.base) - // Phase 6: Upsert the stack (reuse stacks from phase 3) + // Upsert the stack (reuse the stacks fetched above). prNumbers := make([]int, len(resolved)) for i, r := range resolved { prNumbers[i] = r.prNumber @@ -163,6 +236,159 @@ func runLink(cfg *config.Config, opts *linkOptions, args []string) error { return upsertStack(cfg, client, stacks, prNumbers) } +// runLinkAdd appends the resolved PR args to the top of an existing stack. PRs +// already in the target stack are skipped (idempotent); PRs that belong to a +// different stack are rejected. Branch args without a PR get one created and +// chained on top of the stack's current top branch. +func runLinkAdd(cfg *config.Config, client github.ClientOps, opts *linkOptions, target *github.RemoteStack, stacks []github.RemoteStack, prArgs []string, found []*resolvedArg, templateContent string) error { + if opts.baseChanged { + cfg.Warningf("--base is ignored when adding to stack #%d (its base is fixed by the existing stack)", target.Number) + } + + inTarget := make(map[int]bool, len(target.PRNumbers())) + for _, n := range target.PRNumbers() { + inTarget[n] = true + } + + // Partition the args into those already in the target stack (skipped) and + // those to append. found is parallel to prArgs; a nil entry is a branch + // with no PR yet, which is always new. + var appendArgs []string + var appendFound []*resolvedArg + for i, arg := range prArgs { + r := found[i] + if r != nil && inTarget[r.prNumber] { + cfg.Infof("PR %s is already in stack #%d — skipping", + cfg.PRLink(r.prNumber, r.prURL), target.Number) + continue + } + appendArgs = append(appendArgs, arg) + appendFound = append(appendFound, r) + } + + // Enforce the one-stack constraint: none of the PRs being appended may + // belong to a different stack. + if err := ensureNotInOtherStack(cfg, stacks, target.Number, appendFound); err != nil { + return err + } + + if len(appendArgs) == 0 { + cfg.Successf("Stack #%d is already up to date", target.Number) + return nil + } + + // Validate eligibility of the PRs being appended. Target members were + // filtered out above, so every remaining PR faces the full checks. + if err := validatePREligibility(cfg, appendFound, nil); err != nil { + return err + } + + // New PRs chain on top of the stack's current top branch. The stack list + // response should carry per-PR head refs, but fall back to fetching the + // full stack if the top branch can't be resolved from the listed stack. + topBranch, err := stackTopBranch(target) + if err != nil { + if full, gerr := client.GetStack(target.Number); gerr == nil && full != nil { + topBranch, err = stackTopBranch(full) + } + if err != nil { + cfg.Errorf("%s", err) + return ErrAPIFailure + } + } + + needsCreation := 0 + for _, r := range appendFound { + if r == nil { + needsCreation++ + } + } + if needsCreation > 0 { + cfg.Printf("Creating %d %s...", needsCreation, plural(needsCreation, "PR", "PRs")) + } + resolved, err := createMissingPRs(cfg, client, opts, appendArgs, appendFound, templateContent, topBranch) + if err != nil { + return err + } + + // Correct base branches so the appended PRs chain on top of the stack. + fixBaseBranches(cfg, client, opts, resolved, topBranch) + + delta := make([]int, len(resolved)) + for i, r := range resolved { + delta[i] = r.prNumber + } + + return addToStack(cfg, client, target.Number, delta) +} + +// ensureNotInOtherStack verifies that none of the resolved PRs belong to a +// stack other than the target. PRs in no stack are allowed. Reports every +// offender before returning an error. +func ensureNotInOtherStack(cfg *config.Config, stacks []github.RemoteStack, targetNumber int, found []*resolvedArg) error { + owner := make(map[int]int) + for i := range stacks { + for _, n := range stacks[i].PRNumbers() { + owner[n] = stacks[i].Number + } + } + + invalid := 0 + for _, r := range found { + if r == nil { + continue + } + if sn, ok := owner[r.prNumber]; ok && sn != targetNumber { + cfg.Errorf("PR %s already belongs to stack #%d — unstack it first", + cfg.PRLink(r.prNumber, r.prURL), sn) + invalid++ + } + } + if invalid > 0 { + return ErrInvalidArgs + } + return nil +} + +// stackTopBranch returns the head branch of the pull request at the top of the +// stack — the base for the first PR appended on top of it. +func stackTopBranch(s *github.RemoteStack) (string, error) { + if len(s.PRDetails) == 0 { + return "", fmt.Errorf("stack #%d has no pull requests to append to", s.Number) + } + top := s.PRDetails[len(s.PRDetails)-1] + if top.Head.Ref == "" { + return "", fmt.Errorf("could not determine the top branch of stack #%d", s.Number) + } + return top.Head.Ref, nil +} + +// addToStack appends the delta PR numbers to the top of the target stack and +// reports the result, translating API errors into typed exit codes. +func addToStack(cfg *config.Config, client github.ClientOps, stackNumber int, delta []int) error { + if _, err := client.AddToStack(stackNumber, delta); err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 404: + cfg.Errorf("Stack #%d no longer exists", stackNumber) + return ErrNotInStack + case 422: + cfg.Errorf("Cannot add to stack: %s", httpErr.Message) + return ErrAPIFailure + default: + cfg.Errorf("Failed to add to stack (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + return ErrAPIFailure + } + } + cfg.Errorf("Failed to add to stack: %v", err) + return ErrAPIFailure + } + + cfg.Successf("Added %d %s to stack #%d", len(delta), plural(len(delta), "PR", "PRs"), stackNumber) + return nil +} + // pushBranchArgs pushes all arguments that correspond to local branches // to the remote. This ensures branches exist on the server before we try // to create or look up PRs. Args that are pure PR numbers (not local @@ -299,13 +525,29 @@ func findExistingPR(cfg *config.Config, client github.ClientOps, arg string) (*r // validatePREligibility checks that all found PRs are eligible to be added // to a stack. Only open or draft PRs without auto-merge enabled are allowed. // Merged, closed, queued, and auto-merge-enabled PRs are rejected. -// Reports all invalid PRs at once before returning. -func validatePREligibility(cfg *config.Config, found []*resolvedArg) error { +// +// PRs that are already members of targetStack are exempt from these checks: +// they are not being added (they are already present), so re-including them — +// as an additive update requires — must not fail the operation. Reports all +// invalid PRs at once before returning. +func validatePREligibility(cfg *config.Config, found []*resolvedArg, targetStack *github.RemoteStack) error { + inTargetStack := make(map[int]bool) + if targetStack != nil { + for _, n := range targetStack.PRNumbers() { + inTargetStack[n] = true + } + } + invalid := 0 for _, r := range found { if r == nil || r.pr == nil { continue } + // PRs already in the target stack are not being added, so the + // eligibility checks below do not apply to them. + if inTargetStack[r.prNumber] { + continue + } pr := r.pr reason := "" switch { @@ -336,7 +578,7 @@ func listStacksSafe(cfg *config.Config, client github.ClientOps) ([]github.Remot if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) return nil, ErrStacksUnavailable } cfg.Errorf("failed to list stacks: %v", err) @@ -345,49 +587,45 @@ func listStacksSafe(cfg *config.Config, client github.ClientOps) ([]github.Remot return stacks, nil } -// prevalidateStack checks whether the known PRs would conflict with -// existing stacks. This runs before creating new PRs so we can fail -// early without leaving orphaned PRs. -func prevalidateStack(cfg *config.Config, stacks []github.RemoteStack, knownPRNumbers []int) error { - matchedStack, err := findMatchingStack(stacks, knownPRNumbers) - if err != nil { - cfg.Errorf("%s", err) - return ErrDisambiguate +// prevalidateStack checks whether adding the known PRs to the matched target +// stack would remove any of the stack's existing PRs. This runs before creating +// new PRs so we can fail early without leaving orphaned PRs. The caller is +// responsible for passing a non-nil matchedStack (the result of +// findMatchingStack); when no stack matches there is nothing to pre-validate. +func prevalidateStack(cfg *config.Config, matchedStack *github.RemoteStack, knownPRNumbers []int) error { + // Check that we won't be removing PRs from the existing stack. + // At this point we only have the known PR numbers (existing PRs). + // New PRs will be created later and added. Since new PRs can't + // match existing stack PRs (they don't exist yet), we just need + // to check that all existing stack PRs are in the known set. + knownSet := make(map[int]bool, len(knownPRNumbers)) + for _, n := range knownPRNumbers { + knownSet[n] = true } - if matchedStack != nil { - // Check that we won't be removing PRs from the existing stack. - // At this point we only have the known PR numbers (existing PRs). - // New PRs will be created later and added. Since new PRs can't - // match existing stack PRs (they don't exist yet), we just need - // to check that all existing stack PRs are in the known set. - knownSet := make(map[int]bool, len(knownPRNumbers)) - for _, n := range knownPRNumbers { - knownSet[n] = true - } - - var dropped []int - for _, n := range matchedStack.PullRequests { - if !knownSet[n] { - dropped = append(dropped, n) - } + var dropped []int + for _, n := range matchedStack.PRNumbers() { + if !knownSet[n] { + dropped = append(dropped, n) } + } - if len(dropped) > 0 { - cfg.Errorf("Cannot update stack: this would remove %s from the stack", - formatPRList(dropped)) - cfg.Printf("Current stack: %s", formatPRList(matchedStack.PullRequests)) - cfg.Printf("Include all existing PRs in the command to update the stack") - return ErrInvalidArgs - } + if len(dropped) > 0 { + cfg.Errorf("Cannot update stack: this would remove %s from the stack", + formatPRList(dropped)) + cfg.Printf("Current stack: %s", formatPRList(matchedStack.PRNumbers())) + cfg.Printf("Include all existing PRs in the command to update the stack") + return ErrInvalidArgs } return nil } // createMissingPRs creates PRs for branches that don't have one yet. -// Returns the fully resolved list with all branches mapped to PRs. -func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg, templateContent string) ([]resolvedArg, error) { +// Returns the fully resolved list with all branches mapped to PRs. bottomBase +// is the base branch for the first PR in the chain; each subsequent PR bases +// off the previous PR's head branch. +func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOptions, args []string, found []*resolvedArg, templateContent, bottomBase string) ([]resolvedArg, error) { resolved := make([]resolvedArg, len(args)) for i, arg := range args { @@ -397,7 +635,7 @@ func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOpt } // Determine the base branch for this PR - baseBranch := opts.base + baseBranch := bottomBase if i > 0 { baseBranch = resolved[i-1].branch } @@ -424,17 +662,17 @@ func createMissingPRs(cfg *config.Config, client github.ClientOps, opts *linkOpt } // fixBaseBranches updates the base branch of existing PRs to match the -// expected stack chain. The first PR should have base = opts.base, +// expected stack chain. The first PR should have base = bottomBase, // each subsequent PR should have base = previous PR's head branch. // Newly created PRs (created=true) are skipped since they already have // the correct base from creation. -func fixBaseBranches(cfg *config.Config, client github.ClientOps, opts *linkOptions, resolved []resolvedArg) { +func fixBaseBranches(cfg *config.Config, client github.ClientOps, opts *linkOptions, resolved []resolvedArg, bottomBase string) { for i, r := range resolved { if r.created { continue } - expectedBase := opts.base + expectedBase := bottomBase if i > 0 { expectedBase = resolved[i-1].branch } @@ -511,7 +749,7 @@ func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.Re var matched *github.RemoteStack for i := range stacks { - for _, n := range stacks[i].PullRequests { + for _, n := range stacks[i].PRNumbers() { if prSet[n] { if matched != nil && matched.ID != stacks[i].ID { return nil, fmt.Errorf("PRs belong to multiple stacks — unstack them first, then re-link") @@ -527,7 +765,7 @@ func findMatchingStack(stacks []github.RemoteStack, prNumbers []int) (*github.Re // createLink creates a new stack with the given PR numbers. func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) error { - _, err := client.CreateStack(prNumbers) + rs, err := client.CreateStack(prNumbers) if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { @@ -536,7 +774,7 @@ func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) er cfg.Errorf("Cannot create stack: %s", httpErr.Message) return ErrAPIFailure case 404: - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) return ErrStacksUnavailable default: cfg.Errorf("Failed to create stack (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) @@ -547,15 +785,19 @@ func createLink(cfg *config.Config, client github.ClientOps, prNumbers []int) er return ErrAPIFailure } - cfg.Successf("Created stack with %d PRs", len(prNumbers)) + cfg.Successf("Created stack with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return nil } // updateLink updates an existing stack with the given PR numbers. -// The update is additive-only: it errors if any existing PRs would be removed. +// The update is additive-only: it errors if any existing PRs would be removed, +// and (because the add endpoint appends to the top) if the existing PRs are not +// an ordered prefix of the desired list. func updateLink(cfg *config.Config, client github.ClientOps, existing *github.RemoteStack, prNumbers []int) error { + current := existing.PRNumbers() + // Check if the input exactly matches the existing stack. - if slicesEqual(existing.PullRequests, prNumbers) { + if slicesEqual(current, prNumbers) { cfg.Successf("Stack with %d PRs is already up to date", len(prNumbers)) return nil } @@ -567,7 +809,7 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re } var dropped []int - for _, n := range existing.PullRequests { + for _, n := range current { if !newSet[n] { dropped = append(dropped, n) } @@ -576,13 +818,22 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re if len(dropped) > 0 { cfg.Errorf("Cannot update stack: this would remove %s from the stack", formatPRList(dropped)) - cfg.Printf("Current stack: %s", formatPRList(existing.PullRequests)) + cfg.Printf("Current stack: %s", formatPRList(current)) cfg.Printf("Include all existing PRs in the command to update the stack") return ErrInvalidArgs } - stackID := strconv.Itoa(existing.ID) - if err := client.UpdateStack(stackID, prNumbers); err != nil { + // The add endpoint appends to the top of the stack, so the existing PRs + // must be an ordered prefix of the desired list. + delta, ok := appendDelta(current, prNumbers) + if !ok { + cfg.Errorf("Cannot update stack: new PRs must be added to the top of the existing stack") + cfg.Printf("Current stack: %s", formatPRList(current)) + return ErrInvalidArgs + } + + rs, err := client.AddToStack(existing.Number, delta) + if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { switch httpErr.StatusCode { @@ -602,7 +853,7 @@ func updateLink(cfg *config.Config, client github.ClientOps, existing *github.Re return ErrAPIFailure } - cfg.Successf("Updated stack to %d PRs", len(prNumbers)) + cfg.Successf("Updated stack to %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return nil } @@ -618,6 +869,22 @@ func slicesEqual(a, b []int) bool { return true } +// appendDelta returns the PR numbers that must be appended to current to reach +// desired, with ok=true, when current is an exact ordered prefix of desired. +// When desired diverges from current (a reorder or removal), ok is false. This +// mirrors the Stacks add endpoint, which only appends to the top of a stack. +func appendDelta(current, desired []int) (delta []int, ok bool) { + if len(current) > len(desired) { + return nil, false + } + for i, n := range current { + if desired[i] != n { + return nil, false + } + } + return desired[len(current):], true +} + func formatPRList(numbers []int) string { if len(numbers) == 0 { return "" diff --git a/cmd/link_test.go b/cmd/link_test.go index b9adf848..d9739611 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -47,9 +47,9 @@ func TestLink_PRNumbers_CreateNewStack(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createdPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -69,7 +69,7 @@ func TestLink_PRNumbers_CreateNewStack(t *testing.T) { } func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { - var updatedID string + var updatedNumber int var updatedPRs []int cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ @@ -83,13 +83,13 @@ func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 7, PullRequests: []int{10, 20}}, + {ID: 7, Number: 7, PullRequests: []int{10, 20}}, }, nil }, - UpdateStackFn: func(stackID string, prNumbers []int) error { - updatedID = stackID + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + updatedNumber = stackNumber updatedPRs = prNumbers - return nil + return &github.RemoteStack{ID: 7, Number: stackNumber, PullRequests: []int{10, 20, 30}}, nil }, } @@ -104,8 +104,8 @@ func TestLink_PRNumbers_UpdateExistingStack(t *testing.T) { output := string(errOut) assert.NoError(t, err) - assert.Equal(t, "7", updatedID) - assert.Equal(t, []int{10, 20, 30}, updatedPRs) + assert.Equal(t, 7, updatedNumber) + assert.Equal(t, []int{30}, updatedPRs) assert.Contains(t, output, "Updated stack to 3 PRs") } @@ -122,12 +122,12 @@ func TestLink_PRNumbers_ExactMatch_NoOp(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 7, PullRequests: []int{10, 20, 30}}, + {ID: 7, Number: 7, PullRequests: []int{10, 20, 30}}, }, nil }, - UpdateStackFn: func(string, []int) error { - t.Fatal("UpdateStack should not be called for exact match") - return nil + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called for exact match") + return nil, nil }, } @@ -158,7 +158,7 @@ func TestLink_PRNumbers_WouldRemovePRs(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 7, PullRequests: []int{10, 20, 30}}, + {ID: 7, Number: 7, PullRequests: []int{10, 20, 30}}, }, nil }, } @@ -190,8 +190,8 @@ func TestLink_PRNumbers_MultipleStacks(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 1, PullRequests: []int{10, 20}}, - {ID: 2, PullRequests: []int{30, 40}}, + {ID: 1, Number: 1, PullRequests: []int{10, 20}}, + {ID: 2, Number: 2, PullRequests: []int{30, 40}}, }, nil }, } @@ -244,7 +244,7 @@ func TestLink_DuplicateArgs(t *testing.T) { func TestLink_StacksUnavailable(t *testing.T) { cfg, _, errR := config.NewTestConfig() - setTestTokenForHost(cfg, "gho_test_oauth_token") + setTestRepo(cfg) cfg.GitHubClientOverride = &github.MockClient{ FindPRByNumberFn: func(n int) (*github.PullRequest, error) { return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil @@ -277,8 +277,8 @@ func TestLink_Create422(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 0, Number: 0}, &api.HTTPError{ StatusCode: 422, Message: "Pull requests must form a stack", } @@ -379,9 +379,9 @@ func TestLink_RejectsQueuedPR(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { t.Fatal("CreateStack should not be called for ineligible PRs") - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil }, } @@ -419,9 +419,9 @@ func TestLink_RejectsAutoMergeEnabledPR(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { t.Fatal("CreateStack should not be called for ineligible PRs") - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil }, } @@ -558,6 +558,196 @@ func TestLink_ReportsMultipleIneligiblePRs(t *testing.T) { assert.Contains(t, output, "auto-merge") } +// Regression test to ensure a queued PR that is already a member of +// the target stack does not block adding new PRs to that same stack. +func TestLink_AllowsQueuedPRAlreadyInStack(t *testing.T) { + var updatedNumber int + var updatedPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, + State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + // PR 100 is the queued bottom PR already in the stack. + if n == 100 { + pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQE_100"} + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, Number: 7, PullRequests: []int{100}}, + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + updatedNumber = stackNumber + updatedPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: stackNumber, PullRequests: []int{100, 101, 102}}, nil + }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { + t.Fatal("CreateStack should not be called when updating an existing stack") + return &github.RemoteStack{ID: 0, Number: 0}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"100", "101", "102"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + require.NoError(t, err) + assert.Equal(t, 7, updatedNumber) + assert.Equal(t, []int{101, 102}, updatedPRs) + assert.NotContains(t, output, "cannot be added to a stack") +} + +// TestLink_AllowsMergedPRAlreadyInStack verifies the exemption also covers +// state-based ineligibility (merged/closed) for PRs already in the stack. +func TestLink_AllowsMergedPRAlreadyInStack(t *testing.T) { + var updatedPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, + State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + if n == 100 { + pr.State = "MERGED" + pr.Merged = true + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 8, Number: 8, PullRequests: []int{100}}, + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + updatedPRs = prNumbers + return &github.RemoteStack{ID: 8, Number: stackNumber, PullRequests: []int{100, 101}}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"100", "101"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + require.NoError(t, err) + assert.Equal(t, []int{101}, updatedPRs) + assert.NotContains(t, output, "cannot be added to a stack") +} + +// TestLink_AllowsAutoMergePRAlreadyInStack verifies the exemption also covers +// auto-merge-enabled PRs already in the stack. +func TestLink_AllowsAutoMergePRAlreadyInStack(t *testing.T) { + var updatedPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, + State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + if n == 100 { + pr.AutoMergeRequest = &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"} + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 9, Number: 9, PullRequests: []int{100}}, + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + updatedPRs = prNumbers + return &github.RemoteStack{ID: 9, Number: stackNumber, PullRequests: []int{100, 101}}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"100", "101"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + require.NoError(t, err) + assert.Equal(t, []int{101}, updatedPRs) + assert.NotContains(t, output, "cannot be added to a stack") +} + +// TestLink_RejectsQueuedPRNotInStack_WhenAddingToExistingStack confirms the +// 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) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, + State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + // PR 200 is queued and is NOT part of the existing stack. + if n == 200 { + pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQE_200"} + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 7, Number: 7, PullRequests: []int{100}}, + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called when a new PR is ineligible") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"100", "200"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "cannot be added to a stack") + assert.Contains(t, output, "queued for merge") +} + // --- Branch name tests --- func TestLink_BranchNames_AllHavePRs(t *testing.T) { @@ -586,9 +776,9 @@ func TestLink_BranchNames_AllHavePRs(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { stackedPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -650,9 +840,9 @@ func TestLink_BranchNames_CreatesMissingPRs(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { stackedPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -710,8 +900,8 @@ func TestLink_BranchNames_AllNeedPRs(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -770,7 +960,7 @@ func TestLink_BranchNames_DefaultDraft(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -814,7 +1004,7 @@ func TestLink_BranchNames_OpenFlag(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -872,7 +1062,7 @@ func TestLink_OpenFlag_ConvertsDraftPRs(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -919,9 +1109,9 @@ func TestLink_MixedArgs_PRNumberAndBranch(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { stackedPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -971,9 +1161,9 @@ func TestLink_NumericArg_PRNotFound_TreatedAsBranch(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { stackedPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -987,6 +1177,51 @@ func TestLink_NumericArg_PRNotFound_TreatedAsBranch(t *testing.T) { assert.Equal(t, []int{50, 51}, stackedPRs) } +func TestLink_NumericFirstArgIsLocalBranch_NotAddMode(t *testing.T) { + // Branch "123" exists locally and stack #123 also exists. The numeric + // branch name must win over add mode, so the args form a new stack rather + // than appending #456 to the unrelated stack #123. + restore := git.SetOps(newLinkGitMock("123", "456")) + defer restore() + + var createdPRs []int + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(int) (*github.PullRequest, error) { return nil, nil }, + FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { + switch branch { + case "123": + return &github.PullRequest{Number: 50, HeadRefName: "123", BaseRefName: "main", URL: "https://github.com/o/r/pull/50"}, nil + case "456": + return &github.PullRequest{Number: 51, HeadRefName: "456", BaseRefName: "123", URL: "https://github.com/o/r/pull/51"}, nil + } + return nil, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(123, linkPR(90, "unrelated-a"), linkPR(91, "unrelated-b")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack must not be called: a numeric branch name should not trigger add mode") + return nil, nil + }, + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + createdPRs = prNumbers + return &github.RemoteStack{ID: 42, Number: 42}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"123", "456"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []int{50, 51}, createdPRs) +} + func TestLink_FixesBaseBranches(t *testing.T) { restore := git.SetOps(newLinkGitMock("feat-a", "feat-b")) defer restore() @@ -1037,7 +1272,7 @@ func TestLink_FixesBaseBranches(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 42, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil }, } cmd := LinkCmd(cfg) @@ -1092,15 +1327,15 @@ func TestLink_UpdateDeletedStack_FallsBackToCreate(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 7, PullRequests: []int{10}}, + {ID: 7, Number: 7, PullRequests: []int{10}}, }, nil }, - UpdateStackFn: func(string, []int) error { - return &api.HTTPError{StatusCode: 404, Message: "Not Found"} + AddToStackFn: func(stackNumber int, _ []int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { created = true - return 99, nil + return &github.RemoteStack{ID: 99, Number: 99}, nil }, } @@ -1148,7 +1383,7 @@ func TestLink_PushesBranchesBeforeResolution(t *testing.T) { return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -1193,7 +1428,7 @@ func TestLink_RemoteFlag(t *testing.T) { return &github.PullRequest{Number: n, HeadRefName: fmt.Sprintf("b%d", n), BaseRefName: "main"}, nil }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -1224,7 +1459,7 @@ func TestLink_SkipsPushForPRNumbersOnly(t *testing.T) { return &github.PullRequest{Number: n, HeadRefName: "b", BaseRefName: "main"}, nil }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -1263,7 +1498,7 @@ func TestLink_PrevalidatesBeforeCreatingPRs(t *testing.T) { }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 7, PullRequests: []int{104, 105, 106}}, + {ID: 7, Number: 7, PullRequests: []int{104, 105, 106}}, }, nil }, } @@ -1446,7 +1681,7 @@ func TestLink_SkipsBaseFix_ForNewlyCreatedPRs(t *testing.T) { }, nil }, ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := LinkCmd(cfg) @@ -1497,7 +1732,7 @@ func TestLink_BranchNames_UsesPRTemplate(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 42, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil }, } cmd := LinkCmd(cfg) @@ -1515,6 +1750,60 @@ func TestLink_BranchNames_UsesPRTemplate(t *testing.T) { assert.NotContains(t, capturedBody, "GitHub Stacks CLI", "footer should not be present when template is used") } +// TestLink_IgnoresSymlinkedPRTemplate verifies that `gh stack link`, when it +// creates missing PRs, does not follow a symlinked PR template. +func TestLink_IgnoresSymlinkedPRTemplate(t *testing.T) { + tmpDir := t.TempDir() + + // The repo's PR template is a symlink to a file outside the repository; + // gh-stack must not follow it. + linked := filepath.Join(t.TempDir(), "linked.txt") + require.NoError(t, os.WriteFile(linked, []byte("LINKED_FILE_CONTENTS"), 0o600)) + + ghDir := filepath.Join(tmpDir, ".github") + require.NoError(t, os.MkdirAll(ghDir, 0o755)) + if err := os.Symlink(linked, filepath.Join(ghDir, "pull_request_template.md")); err != nil { + t.Skipf("symlinks not supported on this platform: %v", err) + } + + mock := newLinkGitMock("feat-a", "feat-b") + mock.RootDirFn = func() (string, error) { return tmpDir, nil } + restore := git.SetOps(mock) + defer restore() + + var capturedBody string + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { + return nil, nil // No existing PRs + }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + capturedBody = body + return &github.PullRequest{ + Number: 1, HeadRefName: head, BaseRefName: base, + URL: "https://github.com/o/r/pull/1", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{}, nil + }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"feat-a", "feat-b"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + assert.NotContains(t, capturedBody, "LINKED_FILE_CONTENTS", "symlinked template contents must not be included in the PR body") + assert.Contains(t, capturedBody, "GitHub Stacks CLI", "template ignored, footer fallback should be used") +} + 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. @@ -1551,7 +1840,7 @@ func TestLink_PRNumbers_NoTemplateUsesFooter(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func([]int) (int, error) { return 42, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 42, Number: 42}, nil }, } cmd := LinkCmd(cfg) @@ -1584,9 +1873,9 @@ func TestLink_PRURLs_CreateNewStack(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createdPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1649,9 +1938,9 @@ func TestLink_MixedURLsAndNumbers(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createdPRs = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1669,3 +1958,560 @@ func TestLink_MixedURLsAndNumbers(t *testing.T) { assert.Equal(t, []int{10, 20, 30}, createdPRs) assert.Contains(t, output, "Created stack with 3 PRs") } + +// --- add-mode tests (first arg is a stack number) --- + +// linkPR builds an open RemoteStackPR with the given number and head ref. +func linkPR(num int, head string) github.RemoteStackPR { + return github.RemoteStackPR{ + Number: num, + State: "open", + Head: github.RemoteStackPRHead{Ref: head}, + } +} + +// linkRemoteStack builds a RemoteStack with matching PullRequests and PRDetails +// (bottom to top) for add-mode tests, so stackTopBranch can resolve the top ref. +func linkRemoteStack(number int, details ...github.RemoteStackPR) github.RemoteStack { + rs := github.RemoteStack{ID: number, Number: number} + for _, d := range details { + rs.PullRequests = append(rs.PullRequests, d.Number) + rs.PRDetails = append(rs.PRDetails, d) + } + return rs +} + +func TestLink_AddMode_AppendsPRNumberToStack(t *testing.T) { + var addNumber int + var addPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), + BaseRefName: "branch-20", // already chained on the stack top + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addNumber = stackNumber + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { + t.Fatal("CreateStack should not be called in add mode") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, 7, addNumber) + assert.Equal(t, []int{30}, addPRs) + assert.Contains(t, output, "Added 1 PR to stack #7") +} + +func TestLink_AddMode_CreatesPRForBranchOnTopOfStack(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feature-c")) + defer restore() + + var created []struct{ base, head string } + var addPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + created = append(created, struct{ base, head string }{base, head}) + return &github.PullRequest{ + Number: 99, State: "OPEN", HeadRefName: head, BaseRefName: base, + URL: "https://github.com/o/r/pull/99", + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "feature-c"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + require.Len(t, created, 1) + assert.Equal(t, "branch-20", created[0].base) // chains on the stack top branch + assert.Equal(t, "feature-c", created[0].head) + assert.Equal(t, []int{99}, addPRs) + assert.Contains(t, output, "Added 1 PR to stack #7") +} + +func TestLink_AddMode_IdempotentWhenAllPresent(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-10", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called when nothing new to append") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "already in stack #7") + assert.Contains(t, output, "Stack #7 is already up to date") +} + +func TestLink_AddMode_SkipsPresentAppendsNew(t *testing.T) { + var addPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + base := "branch-20" // new PR chains on the stack top + if n == 20 { + base = "branch-10" + } + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: base, + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "20", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{30}, addPRs) + assert.Contains(t, output, "already in stack #7") + assert.Contains(t, output, "Added 1 PR to stack #7") +} + +func TestLink_AddMode_RejectsPRFromAnotherStack(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + linkRemoteStack(8, linkPR(30, "branch-30"), linkPR(40, "branch-40")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called when a PR is in another stack") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "already belongs to stack #8") +} + +func TestLink_AddMode_RejectsIneligibleNewPR(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + if n == 30 { + pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQE_1"} + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called for an ineligible PR") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "cannot be added to a stack") + assert.Contains(t, output, "queued for merge") +} + +func TestLink_AddMode_ExemptsIneligibleExistingMember(t *testing.T) { + var addPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + pr := &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + } + if n == 20 { + // A queued PR that is already a stack member: it is skipped, + // so its ineligibility must not block appending PR 30. + pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQE_1"} + pr.BaseRefName = "branch-10" + } + return pr, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "20", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{30}, addPRs) + assert.Contains(t, output, "already in stack #7") +} + +func TestLink_NumericFirstArgNotAStack_UsesCreateMode(t *testing.T) { + var createdPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "main", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + // A stack exists but its number (7) does not match arg[0] (10). + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(50, "branch-50"), linkPR(60, "branch-60")), + }, nil + }, + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + createdPRs = prNumbers + return &github.RemoteStack{ID: 42, Number: 42}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called in create mode") + return nil, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"10", "20"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{10, 20}, createdPRs) + assert.Contains(t, output, "Created stack with 2 PRs") +} + +func TestLink_AddMode_WarnsWhenBaseFlagSet(t *testing.T) { + var addPRs []int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"--base", "develop", "7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []int{30}, addPRs) + assert.Contains(t, output, "--base is ignored") +} + +func TestLink_AddMode_ChainsMultipleCreatedPRs(t *testing.T) { + restore := git.SetOps(newLinkGitMock("feat-c", "feat-d")) + defer restore() + + var created []struct{ base, head string } + var addPRs []int + prNum := 100 + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + prNum++ + created = append(created, struct{ base, head string }{base, head}) + return &github.PullRequest{ + Number: prNum, State: "OPEN", HeadRefName: head, BaseRefName: base, + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", prNum), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "feat-c", "feat-d"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + require.Len(t, created, 2) + assert.Equal(t, "branch-20", created[0].base) // first new PR on the stack top + assert.Equal(t, "feat-c", created[0].head) + assert.Equal(t, "feat-c", created[1].base) // second new PR chains off the first + assert.Equal(t, "feat-d", created[1].head) + assert.Equal(t, []int{101, 102}, addPRs) + assert.Contains(t, output, "Added 2 PRs to stack #7") +} + +func TestLink_AddMode_AddToStack422(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 422, Message: "cannot append"} + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrAPIFailure) + assert.Contains(t, output, "Cannot add to stack") + assert.Contains(t, output, "cannot append") +} + +func TestLink_AddMode_AddToStack404_StackGone(t *testing.T) { + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")), + }, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "no longer exists") +} + +func TestLink_AddMode_FetchesFullStackWhenListLacksHeadRefs(t *testing.T) { + var addPRs []int + var getStackCalls int + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + return &github.PullRequest{ + Number: n, State: "OPEN", + HeadRefName: fmt.Sprintf("branch-%d", n), BaseRefName: "branch-20", + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + }, nil + }, + ListStacksFn: func() ([]github.RemoteStack, error) { + // The list response carries PR numbers but omits per-PR head refs. + return []github.RemoteStack{{ + ID: 7, Number: 7, + PullRequests: []int{10, 20}, + PRDetails: []github.RemoteStackPR{ + {Number: 10, State: "open"}, + {Number: 20, State: "open"}, + }, + }}, nil + }, + GetStackFn: func(number int) (*github.RemoteStack, error) { + getStackCalls++ + s := linkRemoteStack(7, linkPR(10, "branch-10"), linkPR(20, "branch-20")) + return &s, nil + }, + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + addPRs = prNumbers + return &github.RemoteStack{ID: 7, Number: 7}, nil + }, + } + + cmd := LinkCmd(cfg) + cmd.SetArgs([]string{"7", "30"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Equal(t, 1, getStackCalls) // fell back to fetch the full stack + assert.Equal(t, []int{30}, addPRs) + assert.Contains(t, output, "Added 1 PR to stack #7") +} diff --git a/cmd/modify.go b/cmd/modify.go index 9e42aabc..ba2e5404 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -219,8 +219,12 @@ func runModifyAbort(cfg *config.Config) error { } switch state.Phase { - case modify.PhaseApplying: - cfg.Printf("A modify session was interrupted during the apply phase") + case modify.PhaseApplying, modify.PhaseConflict: + if state.Phase == modify.PhaseConflict { + cfg.Printf("Aborting the modify and discarding in-progress conflict resolution") + } else { + cfg.Printf("A modify session was interrupted during the apply phase") + } cfg.Printf("Restoring stack to pre-modify state...") if err := modify.UnwindFromStateFile(cfg, gitDir); err != nil { cfg.Errorf("recovery failed: %s", err) diff --git a/cmd/modify_test.go b/cmd/modify_test.go index fefdff5c..b6fa9cbb 100644 --- a/cmd/modify_test.go +++ b/cmd/modify_test.go @@ -771,3 +771,132 @@ func TestCheckModifyStateGuard_UnknownPhase(t *testing.T) { err := modify.CheckStateGuard(gitDir) assert.NoError(t, err, "guard only blocks on 'applying' phase") } + +// --------------------------------------------------------------------------- +// 6. runModifyAbort recovery +// --------------------------------------------------------------------------- + +// Regression test: aborting a modify that stopped at a conflict must actually +// unwind the stack (abort the in-flight rebase, reset branch tips to their +// pre-modify SHAs, restore metadata, clear state) — not fall into a default +// branch that merely deletes the state file and strands the user. +func TestRunModifyAbort_ConflictPhase_Unwinds(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + meta, err := json.Marshal(s) + require.NoError(t, err) + + snapshot := modify.Snapshot{ + Branches: []modify.BranchSnapshot{ + {Name: "A", TipSHA: "sha-A-original", Position: 0}, + {Name: "B", TipSHA: "sha-B-original", Position: 1}, + }, + StackMetadata: meta, + } + + // The state ApplyPlan persists when a cascade rebase conflicts. + state := &modify.StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: modify.PhaseConflict, + ConflictBranch: "B", + ConflictType: "rebase", + Snapshot: snapshot, + } + require.NoError(t, modify.SaveState(tmpDir, state)) + + var rebaseAborted bool + var resetCalls []struct{ branch, sha string } + current := "" + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + IsRebaseInProgressFn: func() bool { return true }, + IsCherryPickInProgressFn: func() bool { return false }, + RebaseAbortFn: func() error { rebaseAborted = true; return nil }, + BranchExistsFn: func(string) bool { return true }, + CheckoutBranchFn: func(name string) error { current = name; return nil }, + ResetHardFn: func(sha string) error { + resetCalls = append(resetCalls, struct{ branch, sha string }{current, sha}) + return nil + }, + CreateBranchFn: func(string, string) error { return nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + + err = runModifyAbort(cfg) + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(errR) + output := string(out) + + require.NoError(t, err) + + // The in-flight rebase must have been aborted. + assert.True(t, rebaseAborted, "in-progress rebase should be aborted during recovery") + + // The state file must be cleared because recovery ran (not left dangling, + // and not deleted-without-unwind). + assert.False(t, modify.StateExists(tmpDir), "state file should be cleared after a successful abort") + + // Branch tips must be reset to their pre-modify snapshot SHAs. + resetMap := map[string]string{} + for _, r := range resetCalls { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "sha-A-original", resetMap["A"]) + assert.Equal(t, "sha-B-original", resetMap["B"]) + + // It must not fall into the old default branch. + assert.NotContains(t, output, "unexpected modify state phase") + assert.Contains(t, output, "Restoring stack to pre-modify state") +} + +// PendingSubmit abort is a no-op that guides the user to submit; it must not +// try to unwind (the local changes already succeeded). +func TestRunModifyAbort_PendingSubmit_NoUnwind(t *testing.T) { + tmpDir := t.TempDir() + + state := &modify.StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: modify.PhasePendingSubmit, + } + require.NoError(t, modify.SaveState(tmpDir, state)) + + var resetCalled bool + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + ResetHardFn: func(string) error { resetCalled = true; return nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + + err := runModifyAbort(cfg) + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(errR) + output := string(out) + + require.NoError(t, err) + assert.False(t, resetCalled, "pending-submit abort must not unwind branches") + assert.True(t, modify.StateExists(tmpDir), "pending-submit state should be preserved") + assert.Contains(t, output, "gh stack submit") +} diff --git a/cmd/rebase.go b/cmd/rebase.go index d0c28146..9b79efdc 100644 --- a/cmd/rebase.go +++ b/cmd/rebase.go @@ -198,14 +198,14 @@ func runRebase(cfg *config.Config, opts *rebaseOptions) error { return fmt.Errorf("resolving branch refs: %w", err) } - // Get --onto state from merged/queued branches below the rebase range. - // Ensures that when --upstack excludes skipped branches, we still check - // the immediate predecessor and use --onto if needed. + // Get --onto state from a merged branch immediately below the rebase range. + // Ensures that when --upstack excludes merged branches, we still check the + // immediate predecessor and use --onto if needed. needsOnto := false var ontoOldBase string if startIdx > 0 { prev := s.Branches[startIdx-1] - if prev.IsSkipped() { + if prev.IsMerged() { if sha, ok := originalRefs[prev.Branch]; ok { needsOnto = true ontoOldBase = sha @@ -315,6 +315,14 @@ func continueRebase(cfg *config.Config, gitDir string) error { return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) } + // Refresh PR state before selecting the base and cascading the remaining + // branches. The queued flag is transient (not persisted), so it was lost + // when the stack was reloaded from disk above. Without this, a queued + // branch in the remaining cascade would be treated as active and its + // frozen merge-queue branch would be rebased. Mirrors the syncStackPRs + // call in runRebase before its cascade. + _ = syncStackPRs(cfg, s) + // The branch that had the conflict is stored in state; fall back to // looking it up by index for backwards compatibility with older state files. conflictBranch := state.ConflictBranch @@ -334,10 +342,10 @@ func continueRebase(cfg *config.Config, gitDir string) error { var baseBranch string if state.UseOnto { - // The --onto path targets the first non-skipped ancestor, or trunk. + // The --onto path targets the first non-merged ancestor, or trunk. baseBranch = s.Trunk.Branch for j := state.CurrentBranchIndex - 1; j >= 0; j-- { - if !s.Branches[j].IsSkipped() { + if !s.Branches[j].IsMerged() { baseBranch = s.Branches[j].Branch break } diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go index 611f2b48..683bfe36 100644 --- a/cmd/rebase_test.go +++ b/cmd/rebase_test.go @@ -11,6 +11,7 @@ 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/stack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -680,6 +681,188 @@ func TestRebase_SkipsMergedBranches(t *testing.T) { assert.Equal(t, "b2", rebaseCalls[0].branch) } +// queuedPRClient returns a MockClient whose FindPRByNumber reports the given PR +// numbers as queued (in a merge queue, open, not merged) and finds no PR by +// branch name. Used to drive the transient Queued state through syncStackPRs in +// rebase/sync tests. +func queuedPRClient(headByNumber map[int]string) *github.MockClient { + return &github.MockClient{ + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + head, ok := headByNumber[n] + if !ok { + return nil, nil + } + return &github.PullRequest{ + Number: n, + HeadRefName: head, + State: "OPEN", + Merged: false, + MergeQueueEntry: &github.MergeQueueEntry{ID: fmt.Sprintf("MQ_%d", n)}, + }, nil + }, + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + } +} + +// TestRebase_QueuedBranch_DownstreamStaysStacked verifies the #144 fix: a queued +// PR is NOT treated as merged. Its branch is skipped (frozen in the merge queue), +// but downstream branches stay stacked on top of it — they rebase onto the queued +// branch, not --onto trunk with the queued commits dropped. +func TestRebase_QueuedBranch_DownstreamStaysStacked(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"}) + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + assert.Contains(t, output, "queued") + assert.NotContains(t, output, "adjusted for merged PR", + "queued branches must not trigger the merged --onto path") + + // b2 stays stacked on the queued b1 (not rebased --onto main); b3 onto b2. + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0], + "b2 should rebase onto the queued branch b1, keeping its commits") + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1], + "b3 should rebase onto b2") +} + +// TestRebase_MergedBelowQueued_KeepsStackedOnQueued verifies that when a merged +// branch sits below a queued branch, the branch above the queued one stays +// stacked on the queued branch. The queued branch is frozen and still carries the +// merged branch's commits, so downstream cannot drop them via --onto. +func TestRebase_MergedBelowQueued_KeepsStackedOnQueued(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b3") + mock.BranchExistsFn = func(name string) bool { return true } + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = queuedPRClient(map[int]string{11: "b2"}) + cmd := RebaseCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b1") + assert.Contains(t, output, "PR #10 merged") + assert.Contains(t, output, "Skipping b2") + assert.Contains(t, output, "queued") + + // b1 merged and b2 queued are both skipped. b3 stays stacked on the queued + // b2 — it must NOT be rebased --onto main (which would drop b2's + b1's + // commits while b2 is frozen). + require.Len(t, rebaseCalls, 1) + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0], + "b3 should rebase onto the queued b2, not --onto main") + assert.NotContains(t, output, "adjusted for merged PR") +} + +// TestRebase_UpstackAboveQueuedBranch verifies the onto-seed fix: with --upstack +// starting just above a queued branch, the first in-range branch rebases normally +// onto the queued predecessor rather than dropping its commits via --onto. +func TestRebase_UpstackAboveQueuedBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.BranchExistsFn = func(name string) bool { return true } + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"}) + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--upstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + // upstack from b2 = [b2, b3]; b1 (queued) is below the range. + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseCalls[0], + "b2 should rebase onto the queued predecessor b1, not --onto main") + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[1], + "b3 should rebase onto b2") + assert.NotContains(t, output, "adjusted for merged PR") +} + // TestRebase_StateRoundTrip verifies that rebase state can be saved and loaded // back with all fields preserved, including the --onto fields. func TestRebase_StateRoundTrip(t *testing.T) { @@ -791,6 +974,80 @@ func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) { assert.Contains(t, checkouts, "b1", "should checkout original branch") } +// TestRebase_Continue_QueuedBranchBelowConflict verifies that a queued branch is +// still skipped when the cascade resumes via --continue after a conflict below +// it. The Queued flag is transient and lost when continueRebase reloads the +// stack from disk, so it must be refreshed before the remaining cascade — else +// the frozen merge-queue branch would be rebased. +func TestRebase_Continue_QueuedBranchBelowConflict(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 20}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // State: b1 (below the queued b2) conflicted; b2 and b3 remain. + state := &rebaseState{ + CurrentBranchIndex: 0, + ConflictBranch: "b1", + RemainingBranches: []string{"b2", "b3"}, + OriginalBranch: "b3", + OriginalRefs: map[string]string{ + "main": "main-orig-sha", + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b1") + mock.BranchExistsFn = func(name string) bool { return true } + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func(opts git.RebaseOpts) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = queuedPRClient(map[int]string{20: "b2"}) + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "Skipping b2") + assert.Contains(t, output, "queued") + + // Only b3 is rebased, onto the queued b2. The queued b2 itself must not be + // rebased (its branch is frozen in the merge queue). + require.Len(t, rebaseCalls, 1) + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseCalls[0]) + for _, c := range rebaseCalls { + assert.NotEqual(t, "b2", c.branch, "the frozen queued branch must not be rebased") + } +} + // TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is // set (merged branches upstream). With no remaining branches, only // RebaseContinue runs and the state is cleaned up. diff --git a/cmd/submit.go b/cmd/submit.go index a1ba0042..cf4f29c3 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -40,6 +40,10 @@ and draft each PR's title, description, and draft state, then submit them all at once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the editor and use auto-generated titles. +If your branches already have open PRs but no stack on GitHub yet (for example, +after deleting the stack) and you deselect every new PR, press Ctrl+B (or click +the "STACK N PRs" button) to link the existing open PRs into a stack. + This command performs several steps: 1. Pushes all branches to the remote 2. Creates new PRs for the included branches @@ -109,16 +113,11 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { return ErrAPIFailure } - // Pre-flight: abort early if the user is authenticating with a PAT. - if cfg.WarnIfPAT() { - return ErrStacksUnavailable - } - // Verify that the repository has stacked PRs enabled. stacksAvailable := s.ID != "" if !stacksAvailable { if _, err := client.ListStacks(); err != nil { - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) if cfg.IsInteractive() { p := prompter.New(cfg.In, cfg.Out, cfg.Err) proceed, promptErr := p.Confirm("Would you still like to create regular PRs?", false) @@ -202,7 +201,8 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // auto-generated titles and bodies (today's behavior). var drafts map[string]*submitview.PRDraft if cfg.IsInteractive() && !opts.auto { - collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent) + canCreateStack := stacksAvailable && s.ID == "" + collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent, canCreateStack) if tuiErr != nil { cfg.Errorf("failed to run the submit editor: %s", tuiErr) return ErrSilent @@ -263,7 +263,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // returns the per-branch overrides, whether the user cancelled, and any error. // When the stack contains no branches without a PR, it skips the TUI and // returns nil drafts so the normal push/relink path runs. -func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) { +func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string, canCreateStack bool) (map[string]*submitview.PRDraft, bool, error) { // Fill in the real title/description for existing PRs that were synced // without them (e.g. merged branches) so the read-only cards show API data. enrichPRContent(client, prDetails) @@ -290,10 +290,12 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack } model := submitview.New(submitview.Options{ - Nodes: nodes, - Trunk: s.Trunk, - RepoLabel: repoLabel, - Version: Version, + Nodes: nodes, + Trunk: s.Trunk, + RepoLabel: repoLabel, + Version: Version, + CanCreateStack: canCreateStack, + StackNumber: s.Number, }) // Use cell-motion mouse mode (clicks, drag, and wheel) rather than all-motion. @@ -546,11 +548,9 @@ func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *st return s // nothing new to fork — the whole stack is merged and done } - // Capture trunk/prefix before mutating sf.Stacks (RemoveStack/AddStack can + // Capture trunk before mutating sf.Stacks (RemoveStack/AddStack can // reallocate the slice and invalidate the s pointer). trunk := s.Trunk - prefix := s.Prefix - numbered := s.Numbered // The bottom surviving branch re-bases onto the trunk. if base, err := git.MergeBase(forkBranches[0].Branch, trunk.Branch); err == nil { @@ -579,8 +579,6 @@ func maybeForkFromMergedBase(cfg *config.Config, client github.ClientOps, sf *st } sf.AddStack(stack.Stack{ - Prefix: prefix, - Numbered: numbered, Trunk: trunk, Branches: forkBranches, }) @@ -603,7 +601,7 @@ func remoteStackPRs(client github.ClientOps, stackID string) []int { } for _, rs := range stacks { if strconv.Itoa(rs.ID) == stackID { - return rs.PullRequests + return rs.PRNumbers() } } return nil @@ -655,7 +653,15 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S // Delete the old remote stack if state.PriorRemoteStackID != "" { - if err := client.DeleteStack(state.PriorRemoteStackID); err != nil { + number, found, lookupErr := stackNumberByID(client, state.PriorRemoteStackID) + if lookupErr != nil { + cfg.Warningf("Failed to look up existing stack: %v", lookupErr) + cfg.Printf("Run `%s` again to retry", cfg.ColorCyan("gh stack submit")) + return lookupErr + } + if !found { + cfg.Printf("Previous stack already deleted on GitHub") + } else if _, _, 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") @@ -669,6 +675,7 @@ func handlePendingModify(cfg *config.Config, client github.ClientOps, s *stack.S } // Clear the old stack ID so syncStack creates a new one s.ID = "" + s.Number = 0 } return nil @@ -755,7 +762,7 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta // A remote stack already contains some of our PRs. Refuse to silently drop // any PRs it holds that we aren't tracking locally; let the user reconcile. - if dropped := prsMissingFrom(matched.PullRequests, prNumbers); len(dropped) > 0 { + if dropped := prsMissingFrom(matched.PRNumbers(), prNumbers); len(dropped) > 0 { cfg.Warningf("A stack on GitHub already contains %s, which %s not in your local stack", formatPRList(dropped), plural(len(dropped), "is", "are")) cfg.Printf(" Run `%s` to import the full stack", @@ -767,9 +774,10 @@ func reconcileUntrackedStack(cfg *config.Config, client github.ClientOps, s *sta // more on top). Adopt the remote stack ID — recording it locally — and // update the stack with our full, ordered PR list to append any new PRs. s.ID = strconv.Itoa(matched.ID) + s.Number = matched.Number - if slicesEqual(matched.PullRequests, prNumbers) { - cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)", len(prNumbers)) + if slicesEqual(matched.PRNumbers(), prNumbers) { + cfg.Successf("Linked to the existing stack on GitHub (%d PRs, already up to date)%s", len(prNumbers), stackLabel(matched.Number)) return true } @@ -793,12 +801,57 @@ func prsMissingFrom(remote, local []int) []int { return missing } -// updateStack calls the PUT endpoint to sync the full PR list for an existing stack. -// If the remote stack was deleted (404), it clears the local ID and falls through -// to createNewStack so the user doesn't need to re-run the command. -// Returns true when the remote stack was updated (or recreated) successfully. +// updateStack brings the remote stack in line with the local PR list by +// appending any new PRs via the add endpoint. It reads the current remote stack +// to compute the delta; when the desired list isn't a clean append onto the +// remote stack (a reorder or a removal, e.g. merged PRs leaving the stack) it +// leaves the remote stack untouched. If the remote stack is gone (404) it +// clears the local ID and re-creates it. Returns true when the remote stack +// reflects the local stack (updated, already in sync, or recreated). func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { - if err := client.UpdateStack(s.ID, prNumbers); err != nil { + number, err := ensureStackNumber(client, s) + if err != nil || number == 0 { + // Can't resolve the remote stack — treat as missing and (re)create. + s.ID = "" + s.Number = 0 + return createNewStack(cfg, client, s, prNumbers) + } + + remote, err := client.GetStack(number) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + s.ID = "" + s.Number = 0 + return createNewStack(cfg, client, s, prNumbers) + } + cfg.Warningf("Failed to read stack on GitHub: %v", err) + return false + } + + current := remote.PRNumbers() + if slicesEqual(current, prNumbers) { + s.ID = strconv.Itoa(remote.ID) + s.Number = remote.Number + cfg.Successf("Stack on GitHub is up to date with %d PRs%s", len(prNumbers), stackLabel(remote.Number)) + return true + } + + delta, isAppend := appendDelta(current, prNumbers) + if !isAppend || len(delta) == 0 { + // The desired list isn't a clean append onto the remote stack — the add + // endpoint can't express a reorder or removal. This is expected once + // part of the stack has landed and merged PRs have left the stack. + if len(s.MergedBranches()) > 0 { + cfg.Infof("Merged PRs have left the stack on GitHub, so it wasn't updated — your unmerged PRs were pushed and re-based onto the trunk") + } else { + cfg.Warningf("The stack on GitHub differs from your local stack and couldn't be updated automatically") + } + return false + } + + rs, err := client.AddToStack(number, delta) + if err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) { switch httpErr.StatusCode { @@ -806,6 +859,7 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr // Stack was deleted on GitHub — clear the stale ID and // immediately try to re-create it. s.ID = "" + s.Number = 0 return createNewStack(cfg, client, s, prNumbers) case 422: // A merged branch whose ref has been deleted upstream breaks the @@ -826,7 +880,10 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr } return false } - cfg.Successf("Stack updated on GitHub with %d PRs", len(prNumbers)) + + s.ID = strconv.Itoa(rs.ID) + s.Number = rs.Number + cfg.Successf("Stack updated on GitHub with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return true } @@ -834,10 +891,11 @@ func updateStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, pr // three types of 422 errors the API may return. // Returns true when the stack was created or is confirmed already in sync. func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, prNumbers []int) bool { - stackID, err := client.CreateStack(prNumbers) + rs, err := client.CreateStack(prNumbers) if err == nil { - s.ID = strconv.Itoa(stackID) - cfg.Successf("Stack created on GitHub with %d PRs", len(prNumbers)) + s.ID = strconv.Itoa(rs.ID) + s.Number = rs.Number + cfg.Successf("Stack created on GitHub with %d PRs%s", len(prNumbers), stackLabel(rs.Number)) return true } @@ -851,7 +909,7 @@ func createNewStack(cfg *config.Config, client github.ClientOps, s *stack.Stack, case 422: return handleCreate422(cfg, httpErr, prNumbers) case 404: - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) return false default: cfg.Warningf("Failed to create stack on GitHub: %s", httpErr.Message) diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 67058b32..74cf8a17 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -134,8 +134,8 @@ func TestSubmit_CreatesPRsAndStack(t *testing.T) { URL: fmt.Sprintf("https://github.com/owner/repo/pull/%d", prCounter), }, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -194,7 +194,7 @@ func TestSubmit_DefaultDraft(t *testing.T) { createdDraft = draft return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := SubmitCmd(cfg) @@ -236,7 +236,7 @@ func TestSubmit_OpenFlag(t *testing.T) { createdDraft = draft return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := SubmitCmd(cfg) @@ -293,7 +293,7 @@ func TestSubmit_OpenFlag_ConvertsDraftPRs(t *testing.T) { markedReady = append(markedReady, prID) return nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := SubmitCmd(cfg) @@ -412,8 +412,9 @@ func TestSubmit_ForksWhenRemoteStackFullyMerged(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := stack.Stack{ - ID: "42", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, @@ -469,9 +470,9 @@ func TestSubmit_ForksWhenRemoteStackFullyMerged(t *testing.T) { HeadRefName: head, }, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createStackPRs = prNumbers - return 99, nil + return &github.RemoteStack{ID: 99, Number: 99}, nil }, } @@ -562,7 +563,7 @@ func TestSubmit_NoForkWhenRemoteStackHasOpenPR(t *testing.T) { cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 42, PullRequests: []int{1, 2, 3}}}, nil + return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{1, 2, 3}}}, nil }, FindPRByNumberFn: func(n int) (*github.PullRequest, error) { switch n { @@ -585,12 +586,15 @@ func TestSubmit_NoForkWhenRemoteStackHasOpenPR(t *testing.T) { HeadRefName: head, }, nil }, - UpdateStackFn: func(string, []int) error { + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1, 2, 3}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { // Merged-and-deleted base branches break the chain on GitHub. - return &api.HTTPError{ + return nil, &api.HTTPError{ StatusCode: 422, Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/42"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks/42/add"}, } }, } @@ -627,21 +631,27 @@ func TestUpdateStack_BrokenChainAfterMerge(t *testing.T) { return &api.HTTPError{ StatusCode: 422, Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/42"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks/42/add"}, } } t.Run("merged branches present is reported calmly", func(t *testing.T) { s := &stack.Stack{ - ID: "42", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}}, {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3}}, }, } - mock := &github.MockClient{UpdateStackFn: func(string, []int) error { return mustFormErr() }} + mock := &github.MockClient{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1, 2}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { return nil, mustFormErr() }, + } cfg, _, errR := config.NewTestConfig() updateStack(cfg, mock, s, []int{1, 2, 3}) cfg.Err.Close() @@ -653,14 +663,20 @@ func TestUpdateStack_BrokenChainAfterMerge(t *testing.T) { t.Run("no merged branches still warns", func(t *testing.T) { s := &stack.Stack{ - ID: "42", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}}, }, } - mock := &github.MockClient{UpdateStackFn: func(string, []int) error { return mustFormErr() }} + mock := &github.MockClient{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{1}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { return nil, mustFormErr() }, + } cfg, _, errR := config.NewTestConfig() updateStack(cfg, mock, s, []int{1, 2}) cfg.Err.Close() @@ -731,9 +747,9 @@ func TestSyncStack_NewStack_CreateSuccess(t *testing.T) { var gotNumbers []int mock := &github.MockClient{ - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { gotNumbers = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -751,8 +767,9 @@ func TestSyncStack_NewStack_CreateSuccess(t *testing.T) { func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) { s := &stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -760,18 +777,21 @@ func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) { }, } - var gotStackID string + var gotStackNumber int var gotNumbers []int createCalled := false mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: stackNumber, Number: stackNumber, PullRequests: []int{10, 11}}, nil }, - UpdateStackFn: func(stackID string, prNumbers []int) error { - gotStackID = stackID + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + gotStackNumber = stackNumber gotNumbers = prNumbers - return nil + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11, 12}}, nil }, } @@ -783,15 +803,16 @@ func TestSyncStack_ExistingStack_UpdateSuccess(t *testing.T) { output := string(errOut) assert.False(t, createCalled, "CreateStack should not be called when s.ID is set") - assert.Equal(t, "99", gotStackID) - assert.Equal(t, []int{10, 11, 12}, gotNumbers) + assert.Equal(t, 99, gotStackNumber) + assert.Equal(t, []int{12}, gotNumbers) assert.Contains(t, output, "Stack updated on GitHub with 3 PRs") } func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) { s := &stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -799,11 +820,14 @@ func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) { } mock := &github.MockClient{ - UpdateStackFn: func(string, []int) error { - return &api.HTTPError{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 422, Message: "Validation failed", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/99"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks/99/add"}, } }, } @@ -820,8 +844,9 @@ func TestSyncStack_ExistingStack_UpdateFails(t *testing.T) { func TestSyncStack_ExistingStack_Update404(t *testing.T) { s := &stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -830,16 +855,19 @@ func TestSyncStack_ExistingStack_Update404(t *testing.T) { var createCalled bool mock := &github.MockClient{ - UpdateStackFn: func(string, []int) error { - return &api.HTTPError{ + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 404, Message: "Not Found", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/99"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks/99/add"}, } }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createCalled = true - return 55, nil + return &github.RemoteStack{ID: 55, Number: 55}, nil }, } @@ -866,11 +894,11 @@ func TestSyncStack_AlreadyStacked_OurStack(t *testing.T) { } mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 422, Message: "Pull requests #10, #11 are already stacked", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks"}, } }, } @@ -898,11 +926,11 @@ func TestSyncStack_AlreadyStacked_DifferentStack(t *testing.T) { } mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 422, Message: "Pull requests #10, #11 are already stacked", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks"}, } }, } @@ -933,15 +961,15 @@ func TestSyncStack_AdoptsExistingRemoteStack_ExactMatch(t *testing.T) { var createCalled, updateCalled bool mock := &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11}}}, nil + return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil }, - UpdateStackFn: func(string, []int) error { + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true - return nil + return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11}}, nil }, } @@ -973,20 +1001,23 @@ func TestSyncStack_AdoptsExistingRemoteStack_AddsNewPR(t *testing.T) { } var createCalled bool - var gotStackID string + var gotStackNumber int var gotNumbers []int mock := &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11}}}, nil + return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11}}}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 77, Number: stackNumber, PullRequests: []int{10, 11}}, nil }, - UpdateStackFn: func(stackID string, prNumbers []int) error { - gotStackID = stackID + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + gotStackNumber = stackNumber gotNumbers = prNumbers - return nil + return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil }, } @@ -999,8 +1030,8 @@ func TestSyncStack_AdoptsExistingRemoteStack_AddsNewPR(t *testing.T) { assert.False(t, createCalled, "should adopt and update, not create") assert.Equal(t, "77", s.ID, "should adopt the remote stack ID") - assert.Equal(t, "77", gotStackID, "should update the adopted stack") - assert.Equal(t, []int{10, 11, 12}, gotNumbers, "should send the full local PR list") + assert.Equal(t, 77, gotStackNumber, "should update the adopted stack") + assert.Equal(t, []int{12}, gotNumbers, "should send only the new PR delta") assert.Contains(t, output, "Stack updated on GitHub with 3 PRs") } @@ -1018,15 +1049,15 @@ func TestSyncStack_RemoteStackHasExtraPRs_Refuses(t *testing.T) { var createCalled, updateCalled bool mock := &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 77, PullRequests: []int{10, 11, 12}}}, nil + return []github.RemoteStack{{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil }, - UpdateStackFn: func(string, []int) error { + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true - return nil + return &github.RemoteStack{ID: 77, Number: 77, PullRequests: []int{10, 11, 12}}, nil }, } @@ -1058,17 +1089,17 @@ func TestSyncStack_PRsSpanMultipleRemoteStacks_Warns(t *testing.T) { mock := &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 1, PullRequests: []int{10}}, - {ID: 2, PullRequests: []int{11}}, + {ID: 1, Number: 1, PullRequests: []int{10}}, + {ID: 2, Number: 2, PullRequests: []int{11}}, }, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{ID: 0, Number: 0}, nil }, - UpdateStackFn: func(string, []int) error { + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true - return nil + return &github.RemoteStack{ID: 1, Number: 1}, nil }, } @@ -1101,9 +1132,9 @@ func TestSyncStack_ListStacksError_FallsThroughToCreate(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return nil, fmt.Errorf("network down") }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 88, nil + return &github.RemoteStack{ID: 88, Number: 88}, nil }, } @@ -1134,11 +1165,11 @@ func TestSyncStack_AlreadyPartOfAStack_FallbackPhrasing(t *testing.T) { mock := &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 422, Message: "Pull requests are already part of a stack", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks"}, } }, } @@ -1165,11 +1196,11 @@ func TestSyncStack_InvalidChain_422(t *testing.T) { } mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 422, Message: "Pull requests must form a stack, where each PR's base ref is the previous PR's head ref", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks"}, } }, } @@ -1195,11 +1226,11 @@ func TestSyncStack_NotAvailable(t *testing.T) { } mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{ + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{ StatusCode: 404, Message: "Not Found", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks"}, } }, } @@ -1225,13 +1256,13 @@ func TestSyncStack_SkippedForSinglePR(t *testing.T) { createCalled := false updateCalled := false mock := &github.MockClient{ - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, - UpdateStackFn: func(string, []int) error { + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true - return nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1255,9 +1286,9 @@ func TestSyncStack_IncludesMergedBranches(t *testing.T) { var gotNumbers []int mock := &github.MockClient{ - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { gotNumbers = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1280,9 +1311,9 @@ func TestSyncStack_SkipsBranchesWithoutPR(t *testing.T) { var gotNumbers []int mock := &github.MockClient{ - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { gotNumbers = prNumbers - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1343,8 +1374,8 @@ func TestSubmit_UpdatesBaseBranch(t *testing.T) { }{number, base}) return nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1370,8 +1401,9 @@ func TestSubmit_UpdatesBaseBranch(t *testing.T) { func TestSubmit_SkipsBaseUpdateWhenStacked(t *testing.T) { // Stack already exists (s.ID is set), so base updates should be skipped. s := stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -1410,8 +1442,11 @@ func TestSubmit_SkipsBaseUpdateWhenStacked(t *testing.T) { updateCalled = true return nil }, - UpdateStackFn: func(stackID string, prNumbers []int) error { - return nil + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{10, 11}}, nil }, } @@ -1494,8 +1529,8 @@ func TestSubmit_CreatesMissingPRsAndUpdatesExisting(t *testing.T) { }{number, base}) return nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1555,9 +1590,7 @@ func TestSubmit_PreflightCheck_404_BailsOut(t *testing.T) { }, } - // Use an OAuth token so the PAT pre-flight check passes and we - // exercise the ListStacks 404 path. - setTestTokenForHost(cfg, "gho_test_oauth_token") + setTestRepo(cfg) cmd := SubmitCmd(cfg) cmd.SetArgs([]string{"--auto"}) @@ -1610,9 +1643,7 @@ func TestSubmit_PreflightCheck_404_Interactive_UserDeclinesAborts(t *testing.T) }, } - // Use an OAuth token so the PAT pre-flight check passes and we - // exercise the ListStacks 404 path. - setTestTokenForHost(cfg, "gho_test_oauth_token") + setTestRepo(cfg) cmd := SubmitCmd(cfg) cmd.SetArgs([]string{"--auto"}) @@ -1642,9 +1673,9 @@ func TestSyncStack_SkippedWhenStacksUnavailable(t *testing.T) { createCalled := false mock := &github.MockClient{ - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createCalled = true - return 42, nil + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -1699,7 +1730,7 @@ func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) { CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, - CreateStackFn: func([]int) (int, error) { return 99, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 99, Number: 99}, nil }, } cmd := SubmitCmd(cfg) @@ -1717,8 +1748,9 @@ func TestSubmit_PreflightCheck_EmptyList_Proceeds(t *testing.T) { func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { s := stack.Stack{ - ID: "42", // Existing stack — pre-flight check should be skipped. - Trunk: stack.BranchRef{Branch: "main"}, + ID: "42", // Existing stack — pre-flight check should be skipped. + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -1738,7 +1770,7 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { cfg.GitHubClientOverride = &github.MockClient{ ListStacksFn: func() ([]github.RemoteStack, error) { listStacksCallCount++ - return []github.RemoteStack{{ID: 42, PullRequests: []int{10, 11}}}, nil + return []github.RemoteStack{{ID: 42, Number: 42, PullRequests: []int{10, 11}}}, nil }, FindPRByNumberFn: func(number int) (*github.PullRequest, error) { switch number { @@ -1752,7 +1784,12 @@ func TestSubmit_PreflightCheck_SkippedWhenStackIDSet(t *testing.T) { FindPRForBranchFn: func(string) (*github.PullRequest, error) { return &github.PullRequest{Number: 10, URL: "https://github.com/o/r/pull/10"}, nil }, - UpdateStackFn: func(string, []int) error { return nil }, + GetStackFn: func(int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10}}, nil + }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42, PullRequests: []int{10, 11}}, nil + }, } cmd := SubmitCmd(cfg) @@ -1790,15 +1827,18 @@ func newPendingSubmitState(priorStackID string) *modify.StateFile { func TestHandlePendingModify_DeletesOldStack(t *testing.T) { gitDir := t.TempDir() - saveModifyState(t, gitDir, newPendingSubmitState("stack-123")) + saveModifyState(t, gitDir, newPendingSubmitState("123")) - s := &stack.Stack{ID: "stack-123", Trunk: stack.BranchRef{Branch: "main"}} + s := &stack.Stack{ID: "123", Number: 42, Trunk: stack.BranchRef{Branch: "main"}} - var deletedStackID string + var unstackedNumber int client := &github.MockClient{ - DeleteStackFn: func(id string) error { - deletedStackID = id - return nil + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 123, Number: 42}}, nil + }, + UnstackFn: func(number int) (*github.RemoteStack, bool, error) { + unstackedNumber = number + return nil, true, nil }, } @@ -1808,7 +1848,7 @@ func TestHandlePendingModify_DeletesOldStack(t *testing.T) { err := handlePendingModify(cfg, client, s, gitDir) require.NoError(t, err) - assert.Equal(t, "stack-123", deletedStackID) + assert.Equal(t, 42, unstackedNumber) assert.Equal(t, "", s.ID) } @@ -1820,9 +1860,9 @@ func TestHandlePendingModify_NoStateFile(t *testing.T) { deleteCalled := false client := &github.MockClient{ - DeleteStackFn: func(id string) error { + UnstackFn: func(int) (*github.RemoteStack, bool, error) { deleteCalled = true - return nil + return nil, true, nil }, } @@ -1832,7 +1872,7 @@ func TestHandlePendingModify_NoStateFile(t *testing.T) { err := handlePendingModify(cfg, client, s, gitDir) assert.NoError(t, err) - assert.False(t, deleteCalled, "DeleteStack should not be called when no state file exists") + assert.False(t, deleteCalled, "Unstack should not be called when no state file exists") assert.Equal(t, "stack-123", s.ID, "stack ID should remain unchanged") } @@ -1850,9 +1890,9 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) { deleteCalled := false client := &github.MockClient{ - DeleteStackFn: func(id string) error { + UnstackFn: func(int) (*github.RemoteStack, bool, error) { deleteCalled = true - return nil + return nil, true, nil }, } @@ -1862,20 +1902,23 @@ func TestHandlePendingModify_WrongPhase(t *testing.T) { err := handlePendingModify(cfg, client, s, gitDir) assert.NoError(t, err) - assert.False(t, deleteCalled, "DeleteStack should not be called for non-pending_submit phase") + 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") } func TestHandlePendingModify_DeleteFails(t *testing.T) { gitDir := t.TempDir() - saveModifyState(t, gitDir, newPendingSubmitState("stack-456")) + saveModifyState(t, gitDir, newPendingSubmitState("456")) - s := &stack.Stack{ID: "stack-456", Trunk: stack.BranchRef{Branch: "main"}} + s := &stack.Stack{ID: "456", Number: 43, Trunk: stack.BranchRef{Branch: "main"}} client := &github.MockClient{ - DeleteStackFn: func(id string) error { - return fmt.Errorf("server error") + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 456, Number: 43}}, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, fmt.Errorf("server error") }, } @@ -1885,22 +1928,25 @@ func TestHandlePendingModify_DeleteFails(t *testing.T) { err := handlePendingModify(cfg, client, s, gitDir) assert.Error(t, err) - assert.Equal(t, "stack-456", s.ID, "stack ID should NOT be cleared on delete failure") + assert.Equal(t, "456", s.ID, "stack ID should NOT be cleared on delete failure") } func TestHandlePendingModify_Delete404(t *testing.T) { gitDir := t.TempDir() - saveModifyState(t, gitDir, newPendingSubmitState("stack-gone")) + saveModifyState(t, gitDir, newPendingSubmitState("404")) - s := &stack.Stack{ID: "stack-gone", Trunk: stack.BranchRef{Branch: "main"}} + s := &stack.Stack{ID: "404", Number: 44, Trunk: stack.BranchRef{Branch: "main"}} client := &github.MockClient{ - DeleteStackFn: func(id string) error { - return &api.HTTPError{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 404, Number: 44}}, nil + }, + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{ StatusCode: 404, Message: "Not Found", - RequestURL: &url.URL{Path: "/repos/o/r/cli_internal/pulls/stacks/stack-gone"}, + RequestURL: &url.URL{Path: "/repos/o/r/stacks/44"}, } }, } @@ -1943,8 +1989,9 @@ func TestClearPendingModifyState_NoFile(t *testing.T) { func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { s := stack.Stack{ - ID: "old-stack-42", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "42", + Number: 7, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 11}}, @@ -1954,7 +2001,7 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - saveModifyState(t, tmpDir, newPendingSubmitState("old-stack-42")) + saveModifyState(t, tmpDir, newPendingSubmitState("42")) // Track call ordering var callOrder []string @@ -1970,15 +2017,17 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { restore := git.SetOps(mock) defer restore() - var deletedStackID string + var unstackedNumber int var createdStackPRs []int + unstacked := false cfg, _, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - DeleteStackFn: func(id string) error { - deletedStackID = id - callOrder = append(callOrder, "delete:"+id) - return nil + UnstackFn: func(number int) (*github.RemoteStack, bool, error) { + unstackedNumber = number + unstacked = true + callOrder = append(callOrder, fmt.Sprintf("unstack:%d", number)) + return nil, true, nil }, FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { switch branch { @@ -2006,13 +2055,17 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { } return nil, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createdStackPRs = prNumbers callOrder = append(callOrder, "create_stack") - return 99, nil + return &github.RemoteStack{ID: 99, Number: 99}, nil }, ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{}, nil + // The old stack exists until it is unstacked, then it is gone. + if unstacked { + return []github.RemoteStack{}, nil + } + return []github.RemoteStack{{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}}, nil }, } @@ -2027,8 +2080,8 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { assert.NoError(t, err) - // DeleteStack called with old stack ID - assert.Equal(t, "old-stack-42", deletedStackID) + // Unstack called with old stack number + assert.Equal(t, 7, unstackedNumber) // Push called per-branch (3 separate calls, not 1 atomic call) require.Len(t, pushCalls, 3, "should push each branch individually") @@ -2042,13 +2095,13 @@ func TestSubmit_WithPendingModify_SequentialPush(t *testing.T) { // CreateStack called with all 3 PRs assert.Equal(t, []int{10, 11, 12}, createdStackPRs) - // Verify ordering: delete before push, push before create_stack + // Verify ordering: unstack before push, push before create_stack assert.True(t, len(callOrder) >= 5, "expected at least 5 calls, got %d: %v", len(callOrder), callOrder) deleteIdx := -1 firstPushIdx := -1 createIdx := -1 for i, c := range callOrder { - if c == "delete:old-stack-42" && deleteIdx == -1 { + if c == "unstack:7" && deleteIdx == -1 { deleteIdx = i } if c == "push:b1" && firstPushIdx == -1 { @@ -2109,8 +2162,8 @@ func TestSubmit_FetchesBeforePush(t *testing.T) { ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{}, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -2168,7 +2221,7 @@ func TestSubmit_UsesPRTemplate(t *testing.T) { capturedBody = body return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := SubmitCmd(cfg) @@ -2184,7 +2237,11 @@ func TestSubmit_UsesPRTemplate(t *testing.T) { assert.NotContains(t, capturedBody, feedbackURL) } -func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { +// TestSubmit_IgnoresSymlinkedPRTemplate verifies that `gh stack submit --auto` +// does not follow a symlinked PR template. The non-interactive and +// interactive-prefill flows share the same pr.FindTemplate chokepoint, so they +// are covered transitively. +func TestSubmit_IgnoresSymlinkedPRTemplate(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ @@ -2195,14 +2252,23 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - // No template file created + // The repo's PR template is a symlink to a file outside the repository; + // gh-stack must not follow it. + linked := filepath.Join(t.TempDir(), "linked.txt") + require.NoError(t, os.WriteFile(linked, []byte("LINKED_FILE_CONTENTS"), 0o600)) + + ghDir := filepath.Join(tmpDir, ".github") + require.NoError(t, os.MkdirAll(ghDir, 0o755)) + if err := os.Symlink(linked, filepath.Join(ghDir, "pull_request_template.md")); err != nil { + t.Skipf("symlinks not supported on this platform: %v", err) + } var capturedBody string mock := newSubmitMock(tmpDir, "b1") mock.PushFn = func(string, []string, bool, bool) error { return nil } mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { - return []git.CommitInfo{{Subject: "fix bug"}}, nil + return []git.CommitInfo{{Subject: "add feature", Body: "detailed commit body"}}, nil } restore := git.SetOps(mock) defer restore() @@ -2215,7 +2281,7 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { capturedBody = body return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, - CreateStackFn: func([]int) (int, error) { return 1, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } cmd := SubmitCmd(cfg) @@ -2225,93 +2291,54 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { err := cmd.Execute() assert.NoError(t, err) - assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template") - assert.Contains(t, capturedBody, feedbackURL) + assert.NotContains(t, capturedBody, "LINKED_FILE_CONTENTS", "symlinked template contents must not be included in the PR body") + // The template was ignored, so the standard footer fallback is used. + assert.Contains(t, capturedBody, "GitHub Stacks CLI") } -func TestSubmit_PreflightCheck_PAT_BailsOut(t *testing.T) { +func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { s := stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1"}, - {Branch: "b2"}, }, } tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - pushed := false + // No template file created + + var capturedBody string + mock := newSubmitMock(tmpDir, "b1") - mock.PushFn = func(string, []string, bool, bool) error { - pushed = true - return nil + mock.PushFn = func(string, []string, bool, bool) error { return nil } + mock.LogRangeFn = func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{{Subject: "fix bug"}}, nil } restore := git.SetOps(mock) defer restore() - listStacksCalled := false - cfg, _, errR := config.NewTestConfig() + cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { - listStacksCalled = true - return nil, nil - }, - } - - // Simulate a classic PAT — the pre-flight check should abort. - setTestTokenForHost(cfg, "ghp_classic_pat_token") - - cmd := SubmitCmd(cfg) - cmd.SetArgs([]string{"--auto"}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - err := cmd.Execute() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.ErrorIs(t, err, ErrStacksUnavailable) - assert.Contains(t, output, "Personal access tokens are not supported by gh stack") - assert.Contains(t, output, "gh auth login") - assert.False(t, pushed, "should not push when using a PAT") - assert.False(t, listStacksCalled, "should not call ListStacks when PAT detected") -} - -func TestSubmit_PreflightCheck_FinegrainedPAT_BailsOut(t *testing.T) { - s := stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{ - {Branch: "b1"}, - {Branch: "b2"}, + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + capturedBody = body + return &github.PullRequest{Number: 1, ID: "PR_1", URL: "https://github.com/o/r/pull/1"}, nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { return &github.RemoteStack{ID: 1, Number: 1}, nil }, } - tmpDir := t.TempDir() - writeStackFile(t, tmpDir, s) - - mock := newSubmitMock(tmpDir, "b1") - restore := git.SetOps(mock) - defer restore() - - cfg, _, errR := config.NewTestConfig() - cfg.GitHubClientOverride = &github.MockClient{} - - setTestTokenForHost(cfg, "github_pat_11AABBCC_xxxx") - cmd := SubmitCmd(cfg) cmd.SetArgs([]string{"--auto"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) err := cmd.Execute() - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.ErrorIs(t, err, ErrStacksUnavailable) - assert.Contains(t, output, "Personal access tokens are not supported by gh stack") + assert.NoError(t, err) + assert.Contains(t, capturedBody, "GitHub Stacks CLI", "footer should be present when no template") + assert.Contains(t, capturedBody, feedbackURL) } func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) { @@ -2359,8 +2386,8 @@ func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) { disabledAutoMergePRIDs = append(disabledAutoMergePRIDs, prID) return nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -2411,8 +2438,8 @@ func TestSubmit_DisableAutoMergeFailure_ContinuesWithWarning(t *testing.T) { DisableAutoMergeFn: func(prID string) error { return fmt.Errorf("permission denied") }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } @@ -2463,8 +2490,8 @@ func TestSubmit_NoAutoMerge_SkipsDisable(t *testing.T) { t.Fatal("DisableAutoMerge should not be called when auto-merge is not enabled") return nil }, - CreateStackFn: func(prNumbers []int) (int, error) { - return 42, nil + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 42, Number: 42}, nil }, } diff --git a/cmd/submit_tui_test.go b/cmd/submit_tui_test.go index 9cbac9f5..a6dbea35 100644 --- a/cmd/submit_tui_test.go +++ b/cmd/submit_tui_test.go @@ -36,7 +36,7 @@ func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) { "b2": {Number: 2, State: "OPEN"}, } - drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "") + drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "", false) require.NoError(t, err) assert.False(t, cancelled) assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil") diff --git a/cmd/sync.go b/cmd/sync.go index 70bd831a..34bbbc05 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -26,16 +26,28 @@ func SyncCmd(cfg *config.Config) *cobra.Command { Short: "Sync the current stack with the remote", Long: `Fetch, rebase, push, and sync PR state for the current stack. -This command performs a safe, non-interactive synchronization: +This command performs a safe synchronization: 1. Fetches the latest changes from the remote - 2. Fast-forwards the trunk branch to match the remote - 3. Cascade-rebases stack branches onto their updated parents - 4. Pushes all branches atomically (using --force-with-lease --atomic) - 5. Syncs PR state from GitHub - 6. Links the stack's open PRs into a stack on GitHub (creating or updating + 2. Reconciles the stack on GitHub with your local stack: pulls down + branches for any PRs added to the stack on GitHub, or prompts you to + resolve a divergence in an interactive terminal + 3. Fast-forwards the trunk branch to match the remote + 4. Cascade-rebases stack branches onto their updated parents + 5. Pushes all branches atomically (using --force-with-lease --atomic) + 6. Syncs PR state from GitHub + 7. Links the stack's open PRs into a stack on GitHub (creating or updating the remote stack object) when two or more PRs exist +If PRs have been added to the stack on GitHub, their branches are pulled +down and appended to your local stack so it mirrors the remote. A clean +"remote is ahead" update happens automatically without prompting. If the +local and remote stacks have diverged, sync prompts (in an interactive +terminal) to use the remote as the source of truth, delete the stack on +GitHub and recreate it later with sync/submit, or cancel. Cancelling — or a +divergence in a non-interactive terminal — aborts the sync without pushing +branches or updating PRs. + If a rebase conflict is detected, all branches are restored to their original state and you are advised to run "gh stack rebase" to resolve conflicts interactively. @@ -99,6 +111,34 @@ func runSync(cfg *config.Config, opts *syncOptions) error { _ = git.FetchBranches(remote, fetchTargets) cfg.Successf("Fetched latest changes from %s", remote) + // --- Step 1b: Reconcile remote-ahead stack changes --- + // Pull in branches for PRs that were added to the stack on GitHub, or + // resolve a divergence, before rebasing and pushing so pulled branches + // participate in the normal flow. Best-effort for stacks tracked on the + // remote; a no-op otherwise. + reconcileRes, err := reconcileRemoteStack(cfg, sf, s, currentBranch, gitDir, remote) + if err != nil { + if errors.Is(err, errInterrupt) { + return ErrSilent + } + return err + } + if reconcileRes.stack != nil { + s = reconcileRes.stack + } + if reconcileRes.stop { + // The reconcile step resolved the situation and there is nothing more to + // do (the user cancelled or deleted the remote stack, or a divergence was + // detected non-interactively). The resolving path already reported the + // outcome, so just exit successfully. + return nil + } + // Reconciling "use remote as source of truth" may have moved us off a + // branch that is no longer in the stack, so re-read the current branch. + if cb, cbErr := git.CurrentBranch(); cbErr == nil { + currentBranch = cb + } + // --- Step 2: Fast-forward trunk --- trunk := s.Trunk.Branch trunkUpdated := fastForwardTrunk(cfg, trunk, remote, currentBranch) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 746739ef..4f76d8e7 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -38,9 +38,9 @@ func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { } return "sha-" + ref, nil }, - IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, - FetchFn: func(string) error { return nil }, - EnableRerereFn: func() error { return nil }, + IsAncestorFn: func(a, d string) (bool, error) { return true, nil }, + FetchFn: func(string) error { return nil }, + EnableRerereFn: func() error { return nil }, IsRebaseInProgressFn: func() bool { return false }, PushFn: func(string, []string, bool, bool) error { return nil }, } @@ -745,6 +745,83 @@ func TestSync_MergedBranch_UsesOnto(t *testing.T) { assert.True(t, pushCalls[0].force) } +// TestSync_QueuedBranch_DownstreamStaysStacked verifies the #144 fix in the sync +// path: a queued branch is skipped from push (frozen in the merge queue) but +// downstream branches stay stacked on top of it — they are NOT rebased --onto +// trunk with the queued commits dropped. +func TestSync_QueuedBranch_DownstreamStaysStacked(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 10}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseOntoCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b2") + // Trunk behind remote to trigger rebase; branches match their remote. + mock.RevParseFn = func(ref string) (string, error) { + switch ref { + case "main": + return "local-sha", nil + case "origin/main": + return "remote-sha", nil + } + if strings.HasPrefix(ref, "origin/") { + return "sha-" + strings.TrimPrefix(ref, "origin/"), nil + } + return "sha-" + ref, nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + rebaseOntoCalls = append(rebaseOntoCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = queuedPRClient(map[int]string{10: "b1"}) + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "queued") + + // b1 is queued → skipped, but downstream stays stacked on it: + // b2 onto b1 (not --onto main), b3 onto b2. + require.Len(t, rebaseOntoCalls, 2) + assert.Equal(t, rebaseCall{"b1", "sha-b1", "b2"}, rebaseOntoCalls[0], + "b2 should rebase onto the queued b1, keeping its commits") + assert.Equal(t, rebaseCall{"b2", "sha-b2", "b3"}, rebaseOntoCalls[1], + "b3 should rebase onto b2") + + // The queued branch is excluded from push; only b2 and b3 are pushed. + require.Len(t, pushCalls, 1) + assert.Equal(t, []string{"b2", "b3"}, pushCalls[0].branches, + "queued b1 must not be pushed") +} + // TestSync_StaleOntoOldBase_FallsBackToMergeBase verifies that when a branch // was already rebased past the merged branch's tip, sync detects the stale // ontoOldBase and falls back to merge-base for the correct divergence point. @@ -1680,13 +1757,13 @@ func TestSync_CreatesRemoteStackWhenPRsExist(t *testing.T) { listCalls++ return nil, nil }, - CreateStackFn: func(prNumbers []int) (int, error) { + CreateStackFn: func(prNumbers []int) (*github.RemoteStack, error) { createdWith = prNumbers - return 7, nil + return &github.RemoteStack{ID: 7, Number: 7}, nil }, - UpdateStackFn: func(string, []int) error { - t.Fatal("UpdateStack should not be called when no remote stack exists") - return nil + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called when no remote stack exists") + return nil, nil }, } @@ -1719,15 +1796,15 @@ func TestSync_AdoptsExistingEqualRemoteStack(t *testing.T) { ghMock := &github.MockClient{ FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { t.Fatal("CreateStack should not be called when the remote stack matches") - return 0, nil + return nil, nil }, - UpdateStackFn: func(string, []int) error { - t.Fatal("UpdateStack should not be called when the remote stack matches") - return nil + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack should not be called when the remote stack matches") + return nil, nil }, } @@ -1752,28 +1829,32 @@ func TestSync_UpdatesPartialRemoteStack(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, s) - var updatedID string - var updatedWith []int + var updatedNumber int + var addedWith []int ghMock := &github.MockClient{ FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102, "b3": 103}), ListStacksFn: func() ([]github.RemoteStack, error) { - return []github.RemoteStack{{ID: 9, PullRequests: []int{101, 102}}}, nil + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { t.Fatal("CreateStack should not be called when a matching stack exists") - return 0, nil + return nil, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + assert.Equal(t, 9, stackNumber) + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102}}, nil }, - UpdateStackFn: func(stackID string, prNumbers []int) error { - updatedID = stackID - updatedWith = prNumbers - return nil + AddToStackFn: func(stackNumber int, prNumbers []int) (*github.RemoteStack, error) { + updatedNumber = stackNumber + addedWith = prNumbers + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil }, } output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) - assert.Equal(t, "9", updatedID) - assert.Equal(t, []int{101, 102, 103}, updatedWith) + assert.Equal(t, 9, updatedNumber) + assert.Equal(t, []int{103}, addedWith) assert.Contains(t, output, "Stack updated on GitHub with 3 PRs") assert.Contains(t, output, "Stack synced") assert.NotContains(t, output, "Branches synced") @@ -1801,9 +1882,9 @@ func TestSync_FewerThanTwoPRs_BranchesSynced(t *testing.T) { listCalled = true return nil, nil }, - CreateStackFn: func([]int) (int, error) { + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true - return 0, nil + return &github.RemoteStack{}, nil }, } @@ -1828,8 +1909,8 @@ func TestSync_StacksUnavailable_BranchesSynced(t *testing.T) { ghMock := &github.MockClient{ FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, - CreateStackFn: func([]int) (int, error) { - return 0, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + CreateStackFn: func([]int) (*github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "Not Found"} }, } @@ -1855,20 +1936,638 @@ func TestSync_PRsSpanMultipleStacks_BranchesSynced(t *testing.T) { FindPRForBranchFn: openPRFinder(map[string]int{"b1": 101, "b2": 102}), ListStacksFn: func() ([]github.RemoteStack, error) { return []github.RemoteStack{ - {ID: 9, PullRequests: []int{101}}, - {ID: 10, PullRequests: []int{102}}, + {ID: 9, Number: 9, PullRequests: []int{101}}, + {ID: 10, Number: 10, PullRequests: []int{102}}, }, nil }, - CreateStackFn: func([]int) (int, error) { createCalled = true; return 0, nil }, - UpdateStackFn: func(string, []int) error { updateCalled = true; return nil }, + CreateStackFn: func([]int) (*github.RemoteStack, error) { createCalled = true; return nil, nil }, + AddToStackFn: func(int, []int) (*github.RemoteStack, error) { updateCalled = true; return nil, nil }, } output := runSyncWithGitHub(t, newSyncMockNoRebase(tmpDir, "b1"), ghMock) assert.False(t, createCalled, "CreateStack should not be called on divergence") - assert.False(t, updateCalled, "UpdateStack should not be called on divergence") + assert.False(t, updateCalled, "AddToStack should not be called on divergence") assert.Contains(t, output, "multiple stacks") assert.NotContains(t, output, "submitting", "divergence guidance should be command-neutral, not submit-specific") assert.Contains(t, output, "Branches synced") assert.NotContains(t, output, "Stack synced") } + +// --- Remote-ahead pull & divergence reconciliation --- + +// runSyncCfg runs sync against tmpDir with the given git mock, allowing the +// caller to configure the Config (GitHub override, interactivity, SelectFn). +// It returns the captured stderr output and the command's error. +func runSyncCfg(t *testing.T, gitMock *git.MockOps, configure func(*config.Config)) (string, error) { + t.Helper() + restore := git.SetOps(gitMock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + if configure != nil { + configure(cfg) + } + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + return string(errOut), err +} + +// prByNumberFinder returns a FindPRByNumber func that reports OPEN PRs for the +// given number->branch map and nil for any other number. +func prByNumberFinder(branchByNum map[int]string) func(int) (*github.PullRequest, error) { + return func(n int) (*github.PullRequest, error) { + b, ok := branchByNum[n] + if !ok { + return nil, nil + } + return &github.PullRequest{ + Number: n, + ID: fmt.Sprintf("PR_%d", n), + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + HeadRefName: b, + State: "OPEN", + }, nil + } +} + +func TestClassifyRemoteStack(t *testing.T) { + tests := []struct { + name string + localActive []string + remoteActive []string + want remoteStackClass + }{ + {"identical", []string{"b1", "b2"}, []string{"b1", "b2"}, remoteStackInSync}, + {"clean append on top", []string{"b1", "b2"}, []string{"b1", "b2", "b3"}, remoteStackCleanAhead}, + {"local ahead", []string{"b1", "b2", "b3"}, []string{"b1", "b2"}, remoteStackLocalAhead}, + {"divergent tip", []string{"b1", "b2", "b3"}, []string{"b1", "b2", "b4"}, remoteStackDivergent}, + {"divergent reorder", []string{"b1", "b2"}, []string{"b2", "b1"}, remoteStackDivergent}, + {"empty local", nil, []string{"b1"}, remoteStackCleanAhead}, + {"empty remote", []string{"b1"}, nil, remoteStackLocalAhead}, + {"both empty", nil, nil, remoteStackInSync}, + {"divergent middle", []string{"b1", "x"}, []string{"b1", "y", "z"}, remoteStackDivergent}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, classifyRemoteStack(tt.localActive, tt.remoteActive)) + }) + } +} + +// TestSync_RemoteAhead_PullsNewBranches verifies the core new behavior: when the +// remote stack has PRs appended on top of the local stack, sync pulls the new +// branches down and adds them to the local stack. +func TestSync_RemoteAhead_PullsNewBranches(t *testing.T) { + s := stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 103}}, + }, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var created, fetched []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.BranchExistsFn = func(name string) bool { return name != "b4" && name != "b5" } + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.FetchBranchesFn = func(_ string, branches []string) error { fetched = append(fetched, branches...); return nil } + mock.SetUpstreamTrackingFn = func(string, string) error { return nil } + + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103, 104, 105}}}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + assert.Equal(t, 9, stackNumber) + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103, 104, 105}}, nil + }, + FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3", 104: "b4", 105: "b5"}), + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock }) + require.NoError(t, err) + + assert.Contains(t, created, "b4") + assert.Contains(t, created, "b5") + assert.Subset(t, fetched, []string{"b4", "b5"}) + assert.Contains(t, output, "Pulling 2 new branches from the remote stack") + assert.Contains(t, output, "Pulled 2 new branches into the stack") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, []string{"b1", "b2", "b3", "b4", "b5"}, sf.Stacks[0].BranchNames()) +} + +// TestSync_RemoteAhead_QueuedBranchNotPushed verifies that a pulled branch whose +// PR is in the merge queue has its transient queued state copied from the fresh +// PR details during reconciliation, so it is not force-pushed by the later push +// step. +func TestSync_RemoteAhead_QueuedBranchNotPushed(t *testing.T) { + s := stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var created []string + var pushes []pushCall + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.BranchExistsFn = func(name string) bool { return name != "b3" } + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.SetUpstreamTrackingFn = func(string, string) error { return nil } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushes = append(pushes, pushCall{remote, branches, force, atomic}) + return nil + } + + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + assert.Equal(t, 9, stackNumber) + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + branch := map[int]string{101: "b1", 102: "b2", 103: "b3"}[n] + if branch == "" { + return nil, nil + } + pr := &github.PullRequest{ + Number: n, ID: fmt.Sprintf("PR_%d", n), + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + HeadRefName: branch, State: "OPEN", + } + if n == 103 { + pr.MergeQueueEntry = &github.MergeQueueEntry{ID: "MQ1"} + } + return pr, nil + }, + } + + _, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock }) + require.NoError(t, err) + + assert.Contains(t, created, "b3", "the queued branch is still pulled into the local stack") + for _, pc := range pushes { + assert.NotContains(t, pc.branches, "b3", "a merge-queued branch must not be pushed") + } +} + +// TestSync_RemoteAhead_DuplicateBranchAborts verifies that pulling a remote +// addition whose branch is already owned by another local stack aborts rather +// than writing the branch into two stacks. +func TestSync_RemoteAhead_DuplicateBranchAborts(t *testing.T) { + tmpDir := t.TempDir() + writeStackFileMulti(t, tmpDir, + stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + }, + stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}}, // another stack already owns b3 + }, + ) + + var created []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil + }, + FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3"}), + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock }) + + assert.Error(t, err) + assert.Contains(t, output, "Cannot pull b3") + assert.NotContains(t, created, "b3", "must not pull a branch owned by another stack") + + sf, loadErr := stack.Load(tmpDir) + require.NoError(t, loadErr) + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "tracked stack unchanged") +} + +// TestSync_Divergent_UseRemote_DirtyCheckErrorAborts verifies that when the +// working-tree status cannot be determined, "use remote" aborts instead of +// treating the tree as clean and running the destructive replace. +func TestSync_Divergent_UseRemote_DirtyCheckErrorAborts(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + ghMock := divergentRemoteMock() + var created []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.HasUncommittedChangesFn = func() (bool, error) { return false, fmt.Errorf("git status failed") } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 0, nil } + }) + + assert.Error(t, err) + assert.Contains(t, output, "Could not determine whether the working tree is clean") + assert.Empty(t, created, "must not replace the local stack when the working-tree check fails") + + sf, loadErr := stack.Load(tmpDir) + require.NoError(t, loadErr) + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames(), "local stack untouched") +} + +// TestSync_RemoteInSync_NoPull verifies that when local and remote match, no +// branches are pulled and no divergence is reported. +func TestSync_RemoteInSync_NoPull(t *testing.T) { + s := stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var created []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102}}}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + assert.Equal(t, 9, stackNumber) + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102}}, nil + }, + FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2"}), + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock }) + require.NoError(t, err) + + assert.Empty(t, created, "no branches should be pulled when in sync") + assert.NotContains(t, output, "Pulling") + assert.NotContains(t, output, "diverged") + assert.Contains(t, output, "Stack synced") +} + +// divergentStack returns a stack file (ID 9) and GitHub mock configured so that +// the local stack [b1,b2,b3] diverges from the remote stack [b1,b2,b4]. +func divergentStack(t *testing.T, tmpDir string) { + t.Helper() + s := stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 103}}, + }, + } + writeStackFile(t, tmpDir, s) +} + +func divergentRemoteMock() *github.MockClient { + return &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 104}}}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 104}}, nil + }, + FindPRByNumberFn: prByNumberFinder(map[int]string{101: "b1", 102: "b2", 103: "b3", 104: "b4"}), + } +} + +// TestSync_Divergent_NonInteractive_Aborts verifies that a divergence in a +// non-interactive terminal aborts the sync: no branches are pushed, no stack API +// mutations occur, guidance is printed, the association is preserved, and it +// exits successfully. +func TestSync_Divergent_NonInteractive_Aborts(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + ghMock := divergentRemoteMock() + var created []string + var pushed bool + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil } + ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) { + t.Fatal("CreateStack must not be called") + return nil, nil + } + ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack must not be called") + return nil, nil + } + ghMock.UnstackFn = func(int) (*github.RemoteStack, bool, error) { + t.Fatal("Unstack must not be called") + return nil, false, nil + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { cfg.GitHubClientOverride = ghMock }) + require.NoError(t, err) + + assert.Empty(t, created) + assert.False(t, pushed, "branches must not be pushed when sync aborts") + assert.Contains(t, output, "diverged") + assert.Contains(t, output, "Sync aborted") + assert.NotContains(t, output, "Branches synced") + assert.NotContains(t, output, "Stack synced") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, "9", sf.Stacks[0].ID, "association is preserved") + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames()) +} + +// TestSync_Divergent_UseRemote replaces the local stack with the remote version. +func TestSync_Divergent_UseRemote(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + ghMock := divergentRemoteMock() + var created []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.BranchExistsFn = func(name string) bool { return name != "b4" } + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.SetUpstreamTrackingFn = func(string, string) error { return nil } + mock.HasUncommittedChangesFn = func() (bool, error) { return false, nil } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 0, nil } + }) + require.NoError(t, err) + + assert.Contains(t, created, "b4") + assert.Contains(t, output, "replaced with the remote version") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, []string{"b1", "b2", "b4"}, sf.Stacks[0].BranchNames()) + assert.Equal(t, "9", sf.Stacks[0].ID) +} + +func TestNearestBranchAfterReplace(t *testing.T) { + newStack := func(branches ...string) *stack.Stack { + s := &stack.Stack{Trunk: stack.BranchRef{Branch: "main"}} + for _, b := range branches { + s.Branches = append(s.Branches, stack.BranchRef{Branch: b}) + } + return s + } + tests := []struct { + name string + old []string + current string + newBranches []string + want string + }{ + {"still in stack", []string{"b1", "b2", "b3"}, "b2", []string{"b1", "b2", "b4"}, "b2"}, + {"dropped top prefers below", []string{"b1", "b2", "b3"}, "b3", []string{"b1", "b2", "b4"}, "b2"}, + {"dropped middle prefers above", []string{"b1", "b2", "b3"}, "b2", []string{"b1", "b3"}, "b3"}, + {"on trunk stays put", []string{"b1", "b2"}, "main", []string{"b1", "b2", "b4"}, "main"}, + {"none survive falls back to top", []string{"x", "y", "z"}, "y", []string{"a", "b", "c"}, "c"}, + {"empty new stack falls back to trunk", []string{"b1"}, "b1", nil, "main"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, nearestBranchAfterReplace(tt.old, tt.current, newStack(tt.newBranches...))) + }) + } +} + +// TestSync_Divergent_UseRemote_SwitchesOffDroppedBranch verifies that when the +// user is on a branch that the remote stack no longer contains, replacing the +// local stack with the remote moves them to the nearest surviving branch. +func TestSync_Divergent_UseRemote_SwitchesOffDroppedBranch(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) // local [b1,b2,b3], remote [b1,b2,b4]; user on b3 (dropped) + + ghMock := divergentRemoteMock() + current := "b3" + var checkouts []string + mock := newSyncMockNoRebase(tmpDir, "b3") + mock.CurrentBranchFn = func() (string, error) { return current, nil } + mock.CheckoutBranchFn = func(name string) error { current = name; checkouts = append(checkouts, name); return nil } + mock.BranchExistsFn = func(name string) bool { return name != "b4" } + mock.CreateBranchFn = func(string, string) error { return nil } + mock.SetUpstreamTrackingFn = func(string, string) error { return nil } + mock.HasUncommittedChangesFn = func() (bool, error) { return false, nil } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 0, nil } + }) + require.NoError(t, err) + + assert.Contains(t, checkouts, "b2", "should switch off dropped branch b3 to nearest surviving branch b2") + assert.NotContains(t, checkouts, "b3", "should never check the dropped branch back out") + assert.Contains(t, output, "Switched to b2") + assert.Contains(t, output, "no longer in the stack") + assert.Equal(t, "b2", current, "should end on b2, not the dropped b3") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, []string{"b1", "b2", "b4"}, sf.Stacks[0].BranchNames()) +} +func TestSync_Divergent_UseRemote_DirtyBlocked(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + ghMock := divergentRemoteMock() + var created []string + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + mock.HasUncommittedChangesFn = func() (bool, error) { return true, nil } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 0, nil } + }) + + assert.Error(t, err) + assert.Contains(t, output, "uncommitted changes") + assert.Empty(t, created) + + sf, loadErr := stack.Load(tmpDir) + require.NoError(t, loadErr) + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames(), "local stack untouched") + assert.Equal(t, "9", sf.Stacks[0].ID) +} + +// TestSync_Divergent_DeleteRemote deletes the diverged remote stack, clears the +// local association, and stops the sync (pointing the user at submit) without +// recreating the stack or pushing. +func TestSync_Divergent_DeleteRemote(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + deleted := false + var deletedNumber int + var pushed bool + ghMock := divergentRemoteMock() + ghMock.UnstackFn = func(number int) (*github.RemoteStack, bool, error) { + deleted = true + deletedNumber = number + return nil, true, nil + } + ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) { + t.Fatal("CreateStack must not be called") + return nil, nil + } + ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack must not be called") + return nil, nil + } + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 1, nil } + }) + require.NoError(t, err) + + assert.True(t, deleted, "remote stack should be deleted") + assert.Equal(t, 9, deletedNumber) + assert.False(t, pushed, "sync should stop after deleting the remote stack") + assert.Contains(t, output, "Deleted the stack on GitHub") + assert.Contains(t, output, "gh stack submit") + assert.NotContains(t, output, "Stack synced") + assert.NotContains(t, output, "Branches synced") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, "", sf.Stacks[0].ID, "local association is cleared") + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames(), "local branches untouched") +} + +// TestSync_Divergent_Cancel makes no changes and preserves the association. +func TestSync_Divergent_Cancel(t *testing.T) { + tmpDir := t.TempDir() + divergentStack(t, tmpDir) + + ghMock := divergentRemoteMock() + ghMock.UnstackFn = func(int) (*github.RemoteStack, bool, error) { + t.Fatal("Unstack must not be called") + return nil, false, nil + } + ghMock.CreateStackFn = func([]int) (*github.RemoteStack, error) { + t.Fatal("CreateStack must not be called") + return nil, nil + } + ghMock.AddToStackFn = func(int, []int) (*github.RemoteStack, error) { + t.Fatal("AddToStack must not be called") + return nil, nil + } + var pushed bool + mock := newSyncMockNoRebase(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { pushed = true; return nil } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { return 2, nil } + }) + require.NoError(t, err) + + assert.False(t, pushed, "branches must not be pushed when the user cancels") + assert.Contains(t, output, "Sync aborted") + assert.NotContains(t, output, "Branches synced") + assert.NotContains(t, output, "Stack synced") + + sf, err := stack.Load(tmpDir) + require.NoError(t, err) + assert.Equal(t, "9", sf.Stacks[0].ID, "association is preserved") + assert.Equal(t, []string{"b1", "b2", "b3"}, sf.Stacks[0].BranchNames()) +} + +// TestSync_MergedBranchPruned_NoFalseDivergence verifies that a merged branch +// (still tracked locally but reported merged by the remote) does not classify as +// a divergence. +func TestSync_MergedBranchPruned_NoFalseDivergence(t *testing.T) { + s := stack.Stack{ + ID: "9", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 103}}, + }, + } + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var created []string + mock := newSyncMockNoRebase(tmpDir, "b2") + mock.CreateBranchFn = func(name, base string) error { created = append(created, name); return nil } + + ghMock := &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}}, nil + }, + GetStackFn: func(stackNumber int) (*github.RemoteStack, error) { + assert.Equal(t, 9, stackNumber) + return &github.RemoteStack{ID: 9, Number: 9, PullRequests: []int{101, 102, 103}}, nil + }, + FindPRByNumberFn: func(n int) (*github.PullRequest, error) { + branch := map[int]string{101: "b1", 102: "b2", 103: "b3"}[n] + if branch == "" { + return nil, nil + } + return &github.PullRequest{ + Number: n, ID: fmt.Sprintf("PR_%d", n), + URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n), + HeadRefName: branch, + State: map[bool]string{true: "MERGED", false: "OPEN"}[n == 101], + Merged: n == 101, + }, nil + }, + } + + output, err := runSyncCfg(t, mock, func(cfg *config.Config) { + cfg.GitHubClientOverride = ghMock + cfg.ForceInteractive = true + cfg.SelectFn = func(_, _ string, _ []string) (int, error) { + t.Fatal("no prompt expected when merged branch is pruned") + return 0, nil + } + }) + require.NoError(t, err) + + assert.Empty(t, created) + assert.NotContains(t, output, "diverged") +} diff --git a/cmd/unstack.go b/cmd/unstack.go index 2292df65..e0251a05 100644 --- a/cmd/unstack.go +++ b/cmd/unstack.go @@ -2,7 +2,7 @@ package cmd import ( "errors" - "fmt" + "strconv" "github.com/cli/go-gh/v2/pkg/api" "github.com/github/gh-stack/internal/config" @@ -13,24 +13,51 @@ import ( ) type unstackOptions struct { - local bool + local bool + stackNumber int } func UnstackCmd(cfg *config.Config) *cobra.Command { opts := &unstackOptions{} cmd := &cobra.Command{ - Use: "unstack", + Use: "unstack []", Aliases: []string{"delete"}, - Short: "Delete a stack locally and on GitHub", - Long: "Remove the current active stack from local tracking and delete it on GitHub. Use --local to only remove local tracking. Full unstack is blocked when every pull request is queued for merge, merging, or already merged", - Example: ` # Delete the stack locally and on GitHub + Short: "Remove a stack locally and on GitHub", + Long: `Remove a stack from local tracking and unstack it on GitHub. + +With no argument, the active stack (the one containing the currently checked out +branch) is unstacked on GitHub and removed from local tracking. + +Provide a stack number (the identifier shown in the GitHub stack UI) to +unstack a specific stack on GitHub. This works from anywhere in the repository, +whether or not the stack is checked out locally — the number is unstacked +directly through the GitHub API. If the stack is also available locally, its +local tracking is removed as well. + +Use --local to only remove local tracking without touching remote (GitHub). + +GitHub decides which pull requests can be unstacked: PRs that are queued for +merge or have auto-merge enabled are left stacked. When some pull requests +remain stacked, the stack is kept (and local tracking, if any, is unchanged).`, + Example: ` # Unstack the current stack locally and on GitHub $ gh stack unstack + # Unstack a specific stack by its number + $ gh stack unstack 7 + # Only remove local tracking (keep the stack on GitHub) $ gh stack unstack --local`, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + n, err := strconv.Atoi(args[0]) + if err != nil || n <= 0 { + cfg.Errorf("invalid stack number %q", args[0]) + return ErrInvalidArgs + } + opts.stackNumber = n + } return runUnstack(cfg, opts) }, } @@ -41,10 +68,40 @@ func UnstackCmd(cfg *config.Config) *cobra.Command { } func runUnstack(cfg *config.Config, opts *unstackOptions) error { + // 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 { + // --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 + } + if !ok { + // The stack number isn't tracked locally. + if opts.local { + // --local never contacts GitHub, and there is nothing to remove + // locally, so there is nothing to do. + cfg.Errorf("stack #%d is not tracked locally", opts.stackNumber) + cfg.Printf("Omit %s to unstack it on GitHub", cfg.ColorCyan("--local")) + return ErrNotInStack + } + return runRemoteUnstack(cfg, opts.stackNumber) + } + return unstackTrackedStack(cfg, opts, result) + } + + // No argument: operate on the active stack for the current branch. result, err := loadStack(cfg, "") if err != nil { return ErrNotInStack } + return unstackTrackedStack(cfg, opts, result) +} + +// unstackTrackedStack unstacks a locally tracked stack: it removes the stack on +// GitHub (unless --local) and then removes it from local tracking. +func unstackTrackedStack(cfg *config.Config, opts *unstackOptions, result *loadStackResult) error { gitDir := result.GitDir if err := modify.CheckStateGuard(gitDir); err != nil { @@ -55,11 +112,13 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { sf := result.StackFile s := result.Stack - // Delete the stack on GitHub first (unless --local). - // Only proceed with local deletion after the remote operation succeeds. + // Unstack on GitHub first (unless --local). The server decides which PRs + // can be unstacked; PRs that are queued for merge or have auto-merge enabled + // are left in place and the stack is kept. Local tracking is only removed + // when the remote stack is fully dissolved. if !opts.local { - if s.ID == "" { - cfg.Warningf("Stack has no remote ID — skipping server-side deletion") + if s.ID == "" && s.Number == 0 { + cfg.Warningf("Stack has no remote ID — skipping server-side unstack") } else { client, err := cfg.GitHubClient() if err != nil { @@ -67,36 +126,24 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { return ErrAPIFailure } - blocked, err := shouldBlockUnstackDelete(client, s) + number, err := ensureStackNumber(client, s) if err != nil { - cfg.Errorf("failed to check pull request states before unstack: %s", err) + cfg.Errorf("failed to look up stack on GitHub: %s", err) return ErrAPIFailure } - if blocked { - cfg.Errorf("Unstacking not allowed. Pull requests that are queued for merge, are merging, or are already merged will remain in the stack.") - return ErrInvalidArgs - } - if err := client.DeleteStack(s.ID); err != nil { - var httpErr *api.HTTPError - if errors.As(err, &httpErr) { - switch httpErr.StatusCode { - case 404: - // Stack already deleted on GitHub — treat as success. - cfg.Warningf("Stack not found on GitHub — continuing with local unstack") - case 422: - cfg.Errorf("Cannot delete stack on GitHub: %s", httpErr.Message) - return ErrAPIFailure - default: - cfg.Errorf("Failed to delete stack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) - return ErrAPIFailure - } - } else { - cfg.Errorf("Failed to delete stack on GitHub: %v", err) - return ErrAPIFailure - } + if number == 0 { + cfg.Warningf("Stack not found on GitHub — continuing with local unstack") } else { - cfg.Successf("Stack deleted on GitHub") + keepLocal, err := unstackNumberOnGitHub(cfg, client, number, true) + if err != nil { + return err + } + if keepLocal { + // Some PRs remain stacked, so the stack still exists on + // GitHub. Keep local tracking so it continues to reflect it. + return nil + } } } } @@ -118,54 +165,74 @@ func runUnstack(cfg *config.Config, opts *unstackOptions) error { return nil } -func shouldBlockUnstackDelete(client github.ClientOps, s *stack.Stack) (bool, error) { - if s == nil || len(s.Branches) == 0 { - return false, nil +// runRemoteUnstack unstacks a stack on GitHub purely by its number, without any +// local tracking. This is the remote-first path that lets `gh stack unstack +// ` run from anywhere in the repository (like `gh stack link`), whether +// or not the stack is checked out locally. +func runRemoteUnstack(cfg *config.Config, number int) error { + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure } + if _, err := unstackNumberOnGitHub(cfg, client, number, false); err != nil { + return err + } + return nil +} - eligible := 0 - ineligible := 0 - for _, b := range s.Branches { - // Respect stored merged status when available in local stack metadata. - if b.PullRequest != nil && b.PullRequest.Merged { - ineligible++ - continue - } - - var ( - pr *github.PullRequest - err error - ) - - if b.PullRequest != nil && b.PullRequest.Number > 0 { - pr, err = client.FindPRByNumber(b.PullRequest.Number) - if err != nil { - return false, fmt.Errorf("checking PR #%d for branch %s: %w", b.PullRequest.Number, b.Branch, err) - } - } else { - pr, err = client.FindPRForBranch(b.Branch) - if err != nil { - return false, fmt.Errorf("checking PR for branch %s: %w", b.Branch, err) +// unstackNumberOnGitHub calls the Unstack API for the given stack number and +// reports the outcome. hasLocalTracking indicates whether the caller has a +// locally tracked stack to reconcile, which changes how a 404 and a partial +// unstack are handled: with local tracking a 404 is an idempotent success (the +// caller finishes removing local state) and a partial unstack keeps local +// tracking; without it a 404 is a hard error because the user targeted a stack +// that does not exist on GitHub. +// +// It returns keepLocal=true when local tracking should be preserved because a +// partial unstack left some PRs stacked. keepLocal is only meaningful when +// hasLocalTracking is true. +func unstackNumberOnGitHub(cfg *config.Config, client github.ClientOps, number int, hasLocalTracking bool) (keepLocal bool, err error) { + _, dissolved, err := client.Unstack(number) + if err != nil { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case 404: + if hasLocalTracking { + // Stack already gone on GitHub — treat as success and let + // the caller finish removing local tracking. + cfg.Warningf("Stack not found on GitHub — continuing with local unstack") + return false, nil + } + // Remote-first: the targeted stack does not exist on GitHub. + cfg.Errorf("stack #%d not found on GitHub", number) + return false, ErrNotInStack + case 422: + // The server refused: every PR is queued for merge or has + // auto-merge enabled, so nothing can be unstacked. + cfg.Errorf("Unstacking not allowed: %s", httpErr.Message) + return false, ErrInvalidArgs + default: + cfg.Errorf("Failed to unstack on GitHub (HTTP %d): %s", httpErr.StatusCode, httpErr.Message) + return false, ErrAPIFailure } } + cfg.Errorf("Failed to unstack on GitHub: %v", err) + return false, ErrAPIFailure + } - // If the PR no longer exists (or branch has no open PR), do not block unstacking. - if pr == nil { - eligible++ - continue - } - - switch { - case pr.State == "MERGED": - ineligible++ - case pr.IsQueued(): - ineligible++ - case pr.IsAutoMergeEnabled(): - ineligible++ - default: - eligible++ + if !dissolved { + // Some PRs (queued for merge or with auto-merge enabled) remain stacked + // on GitHub, so the stack still exists. + cfg.Warningf("Some pull requests are queued for merge or have auto-merge enabled and remain stacked on GitHub") + if hasLocalTracking { + cfg.Printf("The stack was left in place — local tracking is unchanged") + return true, nil } + return false, nil } - return ineligible > 0 && eligible == 0, nil + cfg.Successf("Stack removed on GitHub%s", stackLabel(number)) + return false, nil } diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go index 31f6303f..04817c63 100644 --- a/cmd/unstack_test.go +++ b/cmd/unstack_test.go @@ -37,6 +37,7 @@ func TestUnstack_RemovesStack(t *testing.T) { s1 := stack.Stack{ ID: "42", + Number: 42, Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, } @@ -46,12 +47,12 @@ func TestUnstack_RemovesStack(t *testing.T) { } writeTwoStacks(t, gitDir, s1, s2) - var deletedStackID string + var unstackedNumber int cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - DeleteStackFn: func(stackID string) error { - deletedStackID = stackID - return nil + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil // dissolved }, } err := runUnstack(cfg, &unstackOptions{}) @@ -59,8 +60,8 @@ func TestUnstack_RemovesStack(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "Stack removed from local tracking") - assert.Contains(t, output, "Stack deleted on GitHub") - assert.Equal(t, "42", deletedStackID) + assert.Contains(t, output, "Stack removed on GitHub") + assert.Equal(t, 42, unstackedNumber) sf, err := stack.Load(gitDir) require.NoError(t, err) @@ -88,7 +89,7 @@ func TestUnstack_Local(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "Stack removed") // With --local, the GitHub API should NOT be called. - assert.NotContains(t, output, "Stack deleted on GitHub") + assert.NotContains(t, output, "Stack removed on GitHub") sf, err := stack.Load(gitDir) require.NoError(t, err) @@ -103,7 +104,7 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) { }) defer restore() - // Stack with no ID (never synced to GitHub) + // Stack with no ID/Number (never synced to GitHub) writeStackFile(t, gitDir, stack.Stack{ Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, @@ -112,9 +113,9 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) { apiCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - DeleteStackFn: func(stackID string) error { + UnstackFn: func(int) (*github.RemoteStack, bool, error) { apiCalled = true - return nil + return nil, true, nil }, } err := runUnstack(cfg, &unstackOptions{}) @@ -124,7 +125,49 @@ func TestUnstack_NoStackID_WarnsAndSkipsAPI(t *testing.T) { assert.False(t, apiCalled, "API should not be called when stack has no ID") assert.Contains(t, output, "no remote ID") assert.Contains(t, output, "Stack removed from local tracking") - assert.NotContains(t, output, "Stack deleted on GitHub") + assert.NotContains(t, output, "Stack removed on GitHub") +} + +func TestUnstack_ResolvesNumberFromID(t *testing.T) { + // A local stack that predates the Number field (only ID stored) resolves + // its stack number from the remote list before unstacking. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + }) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil + }, + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil + }, + } + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, unstackedNumber, "should resolve the stack number from the internal ID") + assert.Contains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) } func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { @@ -136,8 +179,9 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { defer restore() writeStackFile(t, gitDir, stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, @@ -146,14 +190,14 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - DeleteStackFn: func(stackID string) error { - return &api.HTTPError{StatusCode: 404, Message: "Not Found"} + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"} }, } err := runUnstack(cfg, &unstackOptions{}) output := collectOutput(cfg, outR, errR) - // 404 means already deleted — should succeed and remove locally + // 404 means already gone — should succeed and remove locally require.NoError(t, err) assert.Contains(t, output, "continuing with local unstack") assert.Contains(t, output, "Stack removed from local tracking") @@ -163,7 +207,7 @@ func TestUnstack_API404_TreatedAsIdempotentSuccess(t *testing.T) { assert.Empty(t, sf.Stacks) } -func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { +func TestUnstack_ServerError_StopsLocalDeletion(t *testing.T) { gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -172,8 +216,9 @@ func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { defer restore() writeStackFile(t, gitDir, stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, @@ -182,15 +227,15 @@ func TestUnstack_API409_ShowsErrorAndStopsLocalDeletion(t *testing.T) { cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - DeleteStackFn: func(stackID string) error { - return &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"} + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 409, Message: "Stack is currently being modified"} }, } err := runUnstack(cfg, &unstackOptions{}) output := collectOutput(cfg, outR, errR) assert.ErrorIs(t, err, ErrAPIFailure) - assert.Contains(t, output, "Failed to delete stack on GitHub (HTTP 409)") + assert.Contains(t, output, "Failed to unstack on GitHub (HTTP 409)") // Should NOT remove locally when remote fails assert.NotContains(t, output, "Stack removed from local tracking") @@ -234,7 +279,10 @@ func TestUnstack_RemovesCorrectStackByPointer(t *testing.T) { assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames(), "should keep the OTHER stack intact") } -func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) { +func TestUnstack_AllLocked_ServerRejects(t *testing.T) { + // Every PR is queued for merge or has auto-merge enabled. The server + // (not the client) rejects the unstack with a 422; the command surfaces the + // error and leaves local tracking in place. gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -243,30 +291,21 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) { defer restore() writeStackFile(t, gitDir, stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ - {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101, Merged: true}}, + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, }, }) - deleteCalled := false + unstackCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindPRByNumberFn: func(number int) (*github.PullRequest, error) { - switch number { - case 101: - return &github.PullRequest{Number: 101, State: "MERGED"}, nil - case 102: - return &github.PullRequest{Number: 102, State: "OPEN", MergeQueueEntry: &github.MergeQueueEntry{ID: "MQE_1"}}, nil - default: - return nil, nil - } - }, - DeleteStackFn: func(stackID string) error { - deleteCalled = true - return nil + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"} }, } @@ -274,7 +313,7 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) { output := collectOutput(cfg, outR, errR) assert.ErrorIs(t, err, ErrInvalidArgs) - assert.False(t, deleteCalled, "DeleteStack should not be called when all PRs are ineligible") + assert.True(t, unstackCalled, "the server decides eligibility, so Unstack is called") assert.Contains(t, output, "Unstacking not allowed") assert.NotContains(t, output, "Stack removed from local tracking") @@ -283,7 +322,9 @@ func TestUnstack_PreflightBlocksDelete_WhenAllPRsIneligible(t *testing.T) { require.Len(t, sf.Stacks, 1) } -func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) { +func TestUnstack_PartialUnstack_KeepsLocalTracking(t *testing.T) { + // Some PRs (queued for merge / auto-merge) remain stacked, so the server + // returns the surviving stack (dissolved=false). Local tracking is kept. gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -292,30 +333,19 @@ func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) { defer restore() writeStackFile(t, gitDir, stack.Stack{ - ID: "99", - Trunk: stack.BranchRef{Branch: "main"}, + ID: "99", + Number: 99, + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, }, }) - deleteCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindPRByNumberFn: func(number int) (*github.PullRequest, error) { - switch number { - case 101: - return &github.PullRequest{Number: 101, State: "MERGED"}, nil - case 102: - return &github.PullRequest{Number: 102, State: "OPEN"}, nil - default: - return nil, nil - } - }, - DeleteStackFn: func(stackID string) error { - deleteCalled = true - return nil + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return &github.RemoteStack{ID: 99, Number: 99, PullRequests: []int{102}}, false, nil }, } @@ -323,16 +353,19 @@ func TestUnstack_PreflightAllowsDelete_WhenMixedEligibility(t *testing.T) { output := collectOutput(cfg, outR, errR) require.NoError(t, err) - assert.True(t, deleteCalled, "DeleteStack should be called when at least one PR is eligible") - assert.Contains(t, output, "Stack deleted on GitHub") - assert.Contains(t, output, "Stack removed from local tracking") + assert.Contains(t, output, "remain stacked on GitHub") + assert.Contains(t, output, "local tracking is unchanged") + assert.NotContains(t, output, "Stack removed from local tracking") + // The stack still exists remotely, so local tracking is preserved. sf, loadErr := stack.Load(gitDir) require.NoError(t, loadErr) - assert.Empty(t, sf.Stacks) + require.Len(t, sf.Stacks, 1) } -func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) { +func TestUnstack_NumberLookupFailure_StopsDeletion(t *testing.T) { + // Resolving the stack number from its ID fails (list API error), so the + // command aborts without touching local tracking. gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -346,15 +379,15 @@ func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) { Branches: []stack.BranchRef{{Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}}, }) - deleteCalled := false + unstackCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindPRByNumberFn: func(number int) (*github.PullRequest, error) { - return nil, errors.New("graphql timeout") + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, errors.New("network error") }, - DeleteStackFn: func(stackID string) error { - deleteCalled = true - return nil + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, true, nil }, } @@ -362,8 +395,8 @@ func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) { output := collectOutput(cfg, outR, errR) assert.ErrorIs(t, err, ErrAPIFailure) - assert.False(t, deleteCalled, "DeleteStack should not be called if preflight fails") - assert.Contains(t, output, "failed to check pull request states before unstack") + assert.False(t, unstackCalled, "Unstack should not be called if number lookup fails") + assert.Contains(t, output, "failed to look up stack on GitHub") assert.NotContains(t, output, "Stack removed from local tracking") sf, loadErr := stack.Load(gitDir) @@ -371,7 +404,8 @@ func TestUnstack_PreflightLookupFailure_StopsDeletion(t *testing.T) { require.Len(t, sf.Stacks, 1) } -func TestUnstack_API422_ShowsInformativeErrorAndStopsLocalDeletion(t *testing.T) { +func TestUnstack_ByStackNumber(t *testing.T) { + // Target a specific stack by its number, regardless of the current branch. gitDir := t.TempDir() restore := git.SetOps(&git.MockOps{ GitDirFn: func() (string, error) { return gitDir, nil }, @@ -379,33 +413,317 @@ func TestUnstack_API422_ShowsInformativeErrorAndStopsLocalDeletion(t *testing.T) }) defer restore() - writeStackFile(t, gitDir, stack.Stack{ + s1 := stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ ID: "99", + Number: 7, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 7}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, unstackedNumber) + assert.Contains(t, output, "Stack removed from local tracking") + + // The targeted stack (number 7 / b3,b4) is removed; the other is kept. + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, []string{"b1", "b2"}, sf.Stacks[0].BranchNames()) +} + +func TestUnstack_ByStackNumber_RemoteOnly_Dissolved(t *testing.T) { + // A stack number that isn't tracked locally is unstacked directly on GitHub + // (remote-first), leaving unrelated local tracking untouched. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + return nil, true, nil // dissolved + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 999, unstackedNumber, "should unstack the requested number on GitHub") + assert.Contains(t, output, "Stack removed on GitHub") + // No local tracking was touched. + assert.NotContains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, 42, sf.Stacks[0].Number, "the unrelated local stack is left intact") +} + +func TestUnstack_ByStackNumber_RemoteOnly_NotFound(t *testing.T) { + // With no local tracking to reconcile, a 404 means the targeted stack does + // not exist on GitHub — a hard error, not an idempotent success. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 404, Message: "Not Found"} + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "stack #999 not found on GitHub") + assert.NotContains(t, output, "Stack removed") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_RemoteOnly_Partial(t *testing.T) { + // Some PRs (queued for merge / auto-merge) remain stacked. There is no local + // tracking to keep, so the command reports the outcome and succeeds. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return &github.RemoteStack{ID: 555, Number: 999, PullRequests: []int{102}}, false, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "remain stacked on GitHub") + // No local tracking is involved, so no local-tracking messaging is shown. + assert.NotContains(t, output, "local tracking is unchanged") + assert.NotContains(t, output, "Stack removed from local tracking") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_RemoteOnly_AllLocked(t *testing.T) { + // Every PR is queued for merge or has auto-merge enabled; the server rejects + // the unstack with a 422 and the command surfaces the error. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + return nil, false, &api.HTTPError{StatusCode: 422, Message: "all pull requests are queued for merge or have auto-merge enabled"} + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrInvalidArgs) + assert.Contains(t, output, "Unstacking not allowed") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_NotTracked_LocalFlag(t *testing.T) { + // --local never contacts GitHub. Targeting a number that isn't tracked + // locally with --local is an error: there is nothing to remove locally. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "42", + Number: 42, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + unstackCalled := false + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, true, nil + }, + } + err := runUnstack(cfg, &unstackOptions{stackNumber: 999, local: true}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.False(t, unstackCalled, "--local must never contact GitHub") + assert.Contains(t, output, "stack #999 is not tracked locally") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_LocalFlag_LegacyStack_NoRemoteCall(t *testing.T) { + // --local must never contact GitHub. A legacy stack (Number == 0) can only + // be matched by number via a remote backfill (ListStacks); under --local + // that lookup must be skipped and the number reported as not tracked. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + // Legacy: internal ID present, Number unset (0). + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", + Trunk: stack.BranchRef{Branch: "main"}, Branches: []stack.BranchRef{ {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, }, }) + listCalled := false + unstackCalled := false cfg, outR, errR := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - FindPRByNumberFn: func(number int) (*github.PullRequest, error) { - return &github.PullRequest{Number: number, State: "OPEN"}, nil + ListStacksFn: func() ([]github.RemoteStack, error) { + listCalled = true + return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil }, - DeleteStackFn: func(stackID string) error { - return &api.HTTPError{StatusCode: 422, Message: "some pull requests cannot be removed from stack"} + UnstackFn: func(int) (*github.RemoteStack, bool, error) { + unstackCalled = true + return nil, true, nil }, } - err := runUnstack(cfg, &unstackOptions{}) + + err := runUnstack(cfg, &unstackOptions{stackNumber: 7, local: true}) output := collectOutput(cfg, outR, errR) - assert.ErrorIs(t, err, ErrAPIFailure) - assert.Contains(t, output, "Cannot delete stack on GitHub") - assert.Contains(t, output, "cannot be removed") - assert.NotContains(t, output, "Stack removed from local tracking") + assert.ErrorIs(t, err, ErrNotInStack) + assert.False(t, listCalled, "--local must not contact GitHub (no ListStacks backfill)") + assert.False(t, unstackCalled, "--local must not contact GitHub") + assert.Contains(t, output, "stack #7 is not tracked locally") + + // Local tracking is untouched. + sf, loadErr := stack.Load(gitDir) + require.NoError(t, loadErr) + require.Len(t, sf.Stacks, 1) +} + +func TestUnstack_ByStackNumber_LegacyStackResolvedByID(t *testing.T) { + // A stack tracked before the number was recorded (Number == 0) is resolved + // by mapping its internal ID to the remote stack number, and the backfilled + // number is persisted. + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + ID: "99", // legacy: internal ID present, Number unset (0) + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 101}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 102}}, + }, + }) + + var unstackedNumber int + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ID: 99, Number: 7, PullRequests: []int{101, 102}}}, nil + }, + UnstackFn: func(n int) (*github.RemoteStack, bool, error) { + unstackedNumber = n + // Some PRs remain stacked, so local tracking is kept. + return &github.RemoteStack{ID: 99, Number: 7, PullRequests: []int{102}}, false, nil + }, + } + + err := runUnstack(cfg, &unstackOptions{stackNumber: 7}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, 7, unstackedNumber, "should resolve the legacy stack and unstack by its remote number") + assert.Contains(t, output, "remain stacked on GitHub") + // The backfilled number is persisted to the stack file. sf, loadErr := stack.Load(gitDir) require.NoError(t, loadErr) require.Len(t, sf.Stacks, 1) + assert.Equal(t, 7, sf.Stacks[0].Number, "the resolved stack number should be persisted") } diff --git a/cmd/utils.go b/cmd/utils.go index 3a822b06..1c5dc139 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -4,11 +4,13 @@ import ( "errors" "fmt" "net/url" + "slices" "strconv" "strings" "sync" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" @@ -70,24 +72,65 @@ func printInterrupt(cfg *config.Config) { cfg.Infof("Received interrupt, aborting operation") } -// warnStacksUnavailableOrPAT prints an appropriate warning when a stacks API -// call returns 404. If the token is a PAT the message focuses on the auth -// issue; otherwise it falls back to the generic "not enabled" message. -func warnStacksUnavailableOrPAT(cfg *config.Config) { - if cfg.WarnIfPAT() { - return - } +// warnStacksUnavailable prints a warning when a stacks API call returns 404, +// indicating stacked PRs are not enabled for the repository. +func warnStacksUnavailable(cfg *config.Config) { cfg.Warningf("Stacked PRs are not enabled for this repository") } -// inputWithPrefill prompts the user for text input with the given prefill -// already editable in the input field. Unlike survey.Input's Default (which -// shows in parentheses), this places the prefill text directly in the -// editable line so the user can append to or modify it. The user's input -// is rendered in cyan for visual distinction from the prompt message. -func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error) { +// stackLabel returns a " (stack #N)" suffix for appending to user-facing +// messages when the human-facing stack number is known, or an empty string +// otherwise. +func stackLabel(number int) string { + if number <= 0 { + return "" + } + return fmt.Sprintf(" (stack #%d)", number) +} + +// stackNumberByID resolves an internal stack ID (as stored in the local stack +// file) to its human-facing stack number by consulting the remote stack list. +// Returns ok=false when no remote stack matches the ID (e.g. it was deleted). +func stackNumberByID(client github.ClientOps, id string) (number int, ok bool, err error) { + if id == "" { + return 0, false, nil + } + stacks, err := client.ListStacks() + if err != nil { + return 0, false, err + } + for _, rs := range stacks { + if strconv.Itoa(rs.ID) == id { + return rs.Number, true, nil + } + } + return 0, false, nil +} + +// ensureStackNumber returns the stack number for s, resolving and caching it +// from the remote stack list by internal ID when the local model predates the +// Number field (older stack files stored only the ID). Returns 0 when the stack +// number can't be determined. +func ensureStackNumber(client github.ClientOps, s *stack.Stack) (int, error) { + if s.Number != 0 { + return s.Number, nil + } + number, found, err := stackNumberByID(client, s.ID) + if err != nil { + return 0, err + } + if found { + s.Number = number + } + return number, nil +} + +// promptInput prompts the user for a single line of text input. The user's +// input is rendered in the accent (cyan) color for visual distinction from the +// prompt message. +func promptInput(cfg *config.Config, prompt string) (string, error) { if cfg.InputFn != nil { - return cfg.InputFn(prompt, prefill) + return cfg.InputFn(prompt) } stdio := terminal.Stdio{In: cfg.In, Out: cfg.Out, Err: cfg.Err} @@ -111,7 +154,7 @@ func inputWithPrefill(cfg *config.Config, prompt, prefill string) (string, error fmt.Fprint(cfg.Out, cyanStart) } - line, err := rr.ReadLineWithDefault(0, []rune(prefill)) + line, err := rr.ReadLine(0) // Reset color after input if useColor { @@ -214,6 +257,117 @@ func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { }, nil } +// lookupStackByNumber looks up the locally tracked stack whose stack number +// matches the given value, without printing a "not tracked" error. It returns +// ok=false (with a nil error) when no local stack resolves to that number — +// including when there is no git repository, since a stack cannot be tracked +// locally without one — so callers can fall back to a remote-only operation. A +// non-nil error signals a real failure (the stack file could not be loaded) that +// has already been reported via cfg. +// +// Stack files created before the number was tracked store only the internal ID +// (Number == 0); such legacy stacks are resolved by mapping their ID to a remote +// stack number so they can still be targeted by number. That mapping contacts +// GitHub (ListStacks), so it is only attempted when allowRemote is true — callers +// 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 { + // Not a git repository — nothing can be tracked locally. + return nil, false, nil + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil, false, fmt.Errorf("failed to load stack state: %w", err) + } + + // Direct match on the tracked stack number. + if result := stackResultByNumber(sf, gitDir, number); result != nil { + return result, true, nil + } + + // No direct match — backfill legacy stacks' numbers from the remote and + // retry, so `gh stack unstack ` also works for stacks tracked + // before the number was recorded locally. This reaches GitHub, so it is + // skipped when the caller requires a purely local lookup. + if allowRemote && backfillLegacyStackNumbers(cfg, sf, gitDir) { + if result := stackResultByNumber(sf, gitDir, number); result != nil { + return result, true, nil + } + } + + return nil, false, nil +} + +// stackResultByNumber returns a loadStackResult for the locally tracked stack +// whose Number matches, or nil when none does. +func stackResultByNumber(sf *stack.StackFile, gitDir string, number int) *loadStackResult { + for i := range sf.Stacks { + if sf.Stacks[i].Number == number { + currentBranch, _ := git.CurrentBranch() + return &loadStackResult{ + GitDir: gitDir, + StackFile: sf, + Stack: &sf.Stacks[i], + CurrentBranch: currentBranch, + } + } + } + return nil +} + +// backfillLegacyStackNumbers fills in the human-facing Number for locally +// tracked stacks that predate it (Number == 0 but ID set) by mapping their +// internal ID to the remote stack list, persisting any updates. Returns true +// when at least one number was filled in. Best-effort: returns false on any +// client or API error rather than failing the caller. +func backfillLegacyStackNumbers(cfg *config.Config, sf *stack.StackFile, gitDir string) bool { + needsResolve := false + for i := range sf.Stacks { + if sf.Stacks[i].Number == 0 && sf.Stacks[i].ID != "" { + needsResolve = true + break + } + } + if !needsResolve { + return false + } + + client, err := cfg.GitHubClient() + if err != nil { + return false + } + stacks, err := client.ListStacks() + if err != nil { + return false + } + numberByID := make(map[string]int, len(stacks)) + for _, rs := range stacks { + numberByID[strconv.Itoa(rs.ID)] = rs.Number + } + + changed := false + for i := range sf.Stacks { + if sf.Stacks[i].Number != 0 || sf.Stacks[i].ID == "" { + continue + } + if n, ok := numberByID[sf.Stacks[i].ID]; ok && n != 0 { + sf.Stacks[i].Number = n + changed = true + } + } + if changed { + if err := stack.Save(gitDir, sf); err != nil { + // Non-fatal: the in-memory backfill still lets us resolve the target. + cfg.Warningf("could not persist stack numbers: %v", err) + } + } + return changed +} + // handleSaveError translates a stack.Save error into the appropriate user // message and exit error. Lock contention and stale-file detection both // return ErrLockFailed (exit 8); other write failures return ErrSilent (exit 1). @@ -529,7 +683,13 @@ func syncStackPRsFromRemote(client github.ClientOps, s *stack.Stack) (map[string var remotePRNumbers []int for _, rs := range stacks { if strconv.Itoa(rs.ID) == s.ID { - remotePRNumbers = rs.PullRequests + remotePRNumbers = rs.PRNumbers() + // Backfill the human-facing stack number for stack files created + // before it was tracked, so callers (view, submit TUI) can display + // it. Persisted by whichever command later saves the stack file. + if s.Number == 0 { + s.Number = rs.Number + } break } } @@ -850,23 +1010,33 @@ func cascadeRebase(opts cascadeRebaseOpts) cascadeRebaseResult { base = s.Branches[absIdx-1].Branch } - // Skip merged and queued branches. + // Skip merged and queued branches — but treat them differently for + // downstream rebasing. if br.IsSkipped() { - ontoOldBase = originalRefs[br.Branch] - needsOnto = true if br.IsMerged() { + // A merged PR's commits are already in trunk, so downstream + // branches must drop them by rebasing --onto the first + // non-merged ancestor. + ontoOldBase = originalRefs[br.Branch] + needsOnto = true cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) - } else if br.IsQueued() { + } else { + // A queued PR is frozen in the merge queue and its commits are + // NOT yet in trunk. Downstream branches must stay stacked on top + // of it, so do not switch to --onto (which would drop its + // commits). Reset onto state in case a merged branch set it. + needsOnto = false cfg.Successf("Skipping %s (PR %s queued)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) } continue } if needsOnto { - // Find --onto target: first non-skipped ancestor, or trunk. + // Find --onto target: first non-merged ancestor, or trunk. Queued + // ancestors keep their commits, so they are valid --onto targets. newBase := s.Trunk.Branch for j := absIdx - 1; j >= 0; j-- { - if !s.Branches[j].IsSkipped() { + if !s.Branches[j].IsMerged() { newBase = s.Branches[j].Branch break } @@ -1154,3 +1324,451 @@ func confirmSaveRemote(cfg *config.Config, remote string) (bool, error) { } return ok, nil } + +// ensureLocalBranchFromRemote creates a local branch tracking remote/ +// if it does not already exist. Merged PRs whose remote ref has been deleted +// are skipped (returns skipped=true, err=nil). A non-merged branch that cannot +// be created is a hard failure (returns ErrSilent). Shared by importRemoteStack +// and the sync remote-ahead pull. +func ensureLocalBranchFromRemote(cfg *config.Config, remote string, pr *github.PullRequest) (skipped bool, err error) { + branch := pr.HeadRefName + if git.BranchExists(branch) { + return false, nil + } + remoteRef := remote + "/" + branch + if createErr := git.CreateBranch(branch, remoteRef); createErr != nil { + if pr.Merged { + cfg.Infof("Skipping merged branch %s", branch) + return true, nil + } + cfg.Errorf("failed to pull branch %s from %s: %v", branch, remoteRef, createErr) + return false, ErrSilent + } + _ = git.SetUpstreamTracking(branch, remote) + cfg.Successf("Pulled branch %s", branch) + return false, nil +} + +// remoteReconcileResult reports how reconcileRemoteStack resolved the +// relationship between the local stack and its tracked remote stack. +type remoteReconcileResult struct { + // stack, when non-nil, is the stack the caller should continue with. It + // differs from the input stack only when "use remote as source of truth" + // rebuilt the stack (which reslices StackFile.Stacks and invalidates the + // original pointer). + stack *stack.Stack + + // stop tells runSync to end the sync immediately after reconcile (before any + // fast-forward, rebase, or push) and exit successfully. Set when the user + // cancels or deletes the remote stack, and when a divergence is detected in a + // non-interactive terminal. The resolving path prints its own outcome message. + stop bool +} + +// remoteStackClass classifies the relationship between the local stack's active +// (non-merged) branches and its tracked remote stack's active branches. +type remoteStackClass int + +const ( + remoteStackInSync remoteStackClass = iota // sequences are identical + remoteStackCleanAhead // local is a strict prefix of remote (remote appended on top) + remoteStackLocalAhead // remote is a strict prefix of local (local appended on top) + remoteStackDivergent // neither is a prefix of the other +) + +// reconcileRemoteStack brings remote-ahead stack changes into the local stack +// and resolves divergences. It runs early in `sync` (after fetch, before +// rebase/push) so any pulled branches participate in the normal flow. +// +// It only acts on stacks tracked on the remote (s.ID != ""). It is best-effort: +// a missing client, stacked PRs being unavailable, or any API error causes it +// to skip so the rest of sync still runs. Untracked stacks (s.ID == "") are +// left to the syncStack/reconcileUntrackedStack path. +func reconcileRemoteStack(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string) (remoteReconcileResult, error) { + var res remoteReconcileResult + + if s.ID == "" { + return res, nil + } + + client, err := cfg.GitHubClient() + if err != nil { + return res, nil + } + + stacks, err := client.ListStacks() + if err != nil { + // Covers 404 (stacked PRs unavailable) and transient API errors. + return res, nil + } + + var remotePRNumbers []int + found := false + for _, rs := range stacks { + if strconv.Itoa(rs.ID) == s.ID { + remotePRNumbers = rs.PRNumbers() + found = true + break + } + } + if !found { + // The remote stack was deleted; the existing updateStack 404 → recreate + // path in syncStack handles this. + return res, nil + } + + prs, err := fetchStackPRDetails(client, remotePRNumbers) + if err != nil { + return res, nil + } + + localActive, remoteActive := activeStackSequences(s, prs) + + switch classifyRemoteStack(localActive, remoteActive) { + case remoteStackInSync, remoteStackLocalAhead: + // Nothing to pull; the existing flow pushes/updates the remote. Copy the + // freshly fetched PR state (merged/queued) onto the local branches so the + // fast-forward/rebase/push steps skip merged and merge-queued branches + // rather than rewriting them before the later PR-sync step runs. + syncRemotePRState(s, prs) + return res, nil + case remoteStackCleanAhead: + return pullRemoteAdditions(cfg, sf, s, gitDir, remote, prs) + default: + return resolveStackDivergence(cfg, client, sf, s, currentBranch, gitDir, remote, prs, remoteActive) + } +} + +// activeStackSequences returns the ordered active (non-merged) branch-name +// sequences for the local stack and the fetched remote PRs. Merged state is +// taken from the freshly fetched remote PRs (by branch name) when available so +// that a locally pruned merged branch does not look like a divergence. +func activeStackSequences(s *stack.Stack, prs []*github.PullRequest) (localActive, remoteActive []string) { + remoteMerged := make(map[string]bool, len(prs)) + for _, pr := range prs { + remoteMerged[pr.HeadRefName] = pr.Merged + if !pr.Merged { + remoteActive = append(remoteActive, pr.HeadRefName) + } + } + for _, b := range s.Branches { + merged := b.IsMerged() + if m, ok := remoteMerged[b.Branch]; ok { + merged = m + } + if !merged { + localActive = append(localActive, b.Branch) + } + } + return localActive, remoteActive +} + +// classifyRemoteStack compares the active local and remote branch sequences. +func classifyRemoteStack(localActive, remoteActive []string) remoteStackClass { + if slicesEqualStr(localActive, remoteActive) { + return remoteStackInSync + } + if isStrictPrefix(localActive, remoteActive) { + return remoteStackCleanAhead + } + if isStrictPrefix(remoteActive, localActive) { + return remoteStackLocalAhead + } + return remoteStackDivergent +} + +// isStrictPrefix reports whether a is a strict prefix of b: a is shorter than b +// and every element of a equals the element at the same position in b. +func isStrictPrefix(a, b []string) bool { + if len(a) >= len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// slicesEqualStr reports whether two string slices are element-wise equal. +func slicesEqualStr(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// pullRemoteAdditions handles the clean append-on-top case: it fetches and +// creates local branches for the remote PRs not yet tracked locally, appends +// them (in remote order) to the stack, and persists. Merged remote PRs with no +// local branch are ignored (they are not part of ongoing local work). +func pullRemoteAdditions(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, gitDir, remote string, prs []*github.PullRequest) (remoteReconcileResult, error) { + var res remoteReconcileResult + + existing := make(map[string]bool, len(s.Branches)) + for _, b := range s.Branches { + existing[b.Branch] = true + } + + var newPRs []*github.PullRequest + for _, pr := range prs { + if pr.Merged || existing[pr.HeadRefName] { + continue + } + newPRs = append(newPRs, pr) + } + if len(newPRs) == 0 { + return res, nil + } + + // A remote-added branch must not collide with a branch already tracked by + // another local stack, or with an existing local branch we would otherwise + // adopt as "pulled" without actually fetching it. Abort rather than persist + // duplicate ownership or a stale branch. + for _, pr := range newPRs { + if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil { + cfg.Errorf("Cannot pull %s from the remote stack: %s", pr.HeadRefName, err) + return res, ErrSilent + } + if git.BranchExists(pr.HeadRefName) { + cfg.Errorf("Cannot pull %s from the remote stack: a local branch with that name already exists", pr.HeadRefName) + return res, ErrSilent + } + } + + newBranchNames := make([]string, len(newPRs)) + for i, pr := range newPRs { + newBranchNames[i] = pr.HeadRefName + } + _ = git.FetchBranches(remote, newBranchNames) + + cfg.Printf("") + cfg.Printf("Pulling %d new %s from the remote stack ...", + len(newPRs), plural(len(newPRs), "branch", "branches")) + + added := 0 + for _, pr := range newPRs { + skipped, err := ensureLocalBranchFromRemote(cfg, remote, pr) + if err != nil { + return res, err + } + if skipped { + continue + } + s.Branches = append(s.Branches, stack.BranchRef{ + Branch: pr.HeadRefName, + PullRequest: &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + }, + }) + added++ + } + + if added > 0 { + // Copy the freshly fetched PR state (including the transient queued flag) + // onto the pulled branches so the rebase/push steps skip merge-queued ones. + syncRemotePRState(s, prs) + updateBaseSHAs(s) + if err := stack.Save(gitDir, sf); err != nil { + return res, handleSaveError(cfg, err) + } + cfg.Successf("Pulled %d new %s into the stack from the remote", + added, plural(added, "branch", "branches")) + } + return res, nil +} + +// resolveStackDivergence handles a stack whose local composition has diverged +// from the remote (neither is a prefix of the other). In an interactive +// terminal it prompts the user to resolve it; otherwise (or when the user +// cancels) it aborts the sync so nothing is pushed or updated. +func resolveStackDivergence(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string, prs []*github.PullRequest, remoteActive []string) (remoteReconcileResult, error) { + cfg.Printf("") + cfg.Warningf("Your local stack has diverged from the stack on GitHub") + cfg.Printf(" Local: %s", s.DisplayChain()) + cfg.Printf(" Remote: (%s) <- %s", s.Trunk.Branch, strings.Join(remoteActive, " <- ")) + + if !cfg.IsInteractive() { + cfg.Printf(" Re-run in an interactive terminal to resolve, or import the remote stack with `%s`.", + cfg.ColorCyan("gh stack checkout ")) + cfg.Infof("Sync aborted — no changes were made") + return remoteReconcileResult{stop: true}, nil + } + + options := []string{ + "Update local to match remote — replace your local stack with the remote version", + "Delete the remote stack on GitHub — keep your local stack and recreate on remote later", + "Cancel — make no changes", + } + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selectFn := func(prompt, def string, opts []string) (int, error) { + if cfg.SelectFn != nil { + return cfg.SelectFn(prompt, def, opts) + } + return p.Select(prompt, def, opts) + } + selected, err := selectFn("How would you like to resolve?", "", options) + if err != nil { + if isInterruptError(err) { + if cfg.SelectFn == nil { + clearSelectPrompt(cfg, len(options)) + } + printInterrupt(cfg) + return remoteReconcileResult{stop: true}, errInterrupt + } + cfg.Errorf("selection failed: %v", err) + return remoteReconcileResult{}, ErrSilent + } + + switch selected { + case 0: + return resolveDivergenceUseRemote(cfg, sf, s, currentBranch, gitDir, remote, prs) + case 1: + return resolveDivergenceDeleteRemote(cfg, client, sf, s, gitDir) + default: + // Cancel: stop the sync without touching branches or PRs. + cfg.Infof("Sync aborted — no changes were made") + return remoteReconcileResult{stop: true}, nil + } +} + +// resolveDivergenceUseRemote replaces the local stack composition with the +// remote's. It refuses when the working tree is dirty (the rebuild is +// destructive to stack tracking). Local-only branches remain as git refs but +// are no longer part of the stack. Returns the rebuilt stack pointer so the +// caller can continue with it (importRemoteStack reslices StackFile.Stacks). +func resolveDivergenceUseRemote(cfg *config.Config, sf *stack.StackFile, s *stack.Stack, currentBranch, gitDir, remote string, prs []*github.PullRequest) (remoteReconcileResult, error) { + var res remoteReconcileResult + + // Replacing the local stack is destructive, so require a known-clean working + // tree. Treat an inability to inspect the tree as a reason to abort (a failed + // status must not be read as "clean"). + dirty, err := git.HasUncommittedChanges() + if err != nil { + cfg.Errorf("Could not determine whether the working tree is clean: %v", err) + return res, ErrSilent + } + if dirty { + cfg.Errorf("You have uncommitted changes — commit or stash them before replacing your local stack with the remote") + return res, ErrSilent + } + + trunk := s.Trunk.Branch + remoteStackID := s.ID + remoteStackNumber := s.Number + oldBranches := s.BranchNames() + + removeLocalStack(sf, s) + + // A remote PR branch must not already be owned by another local stack, or + // importing it would write the same branch into two stacks. Validate against + // the remaining stacks (the current one has been removed above). + for _, pr := range prs { + if err := sf.ValidateNoDuplicateBranch(pr.HeadRefName); err != nil { + cfg.Errorf("Cannot adopt the remote stack: %s", err) + return res, ErrSilent + } + } + + newStack, err := importRemoteStack(cfg, sf, gitDir, remote, trunk, prs, remoteStackID, remoteStackNumber) + if err != nil { + return res, err + } + + // Populate the transient queued/merged state so the rebase/push steps skip + // merge-queued or merged branches in the adopted stack. + syncRemotePRState(newStack, prs) + + // If the user was on a branch that the remote stack no longer contains, + // 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 { + 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) + } + } + + if err := stack.Save(gitDir, sf); err != nil { + return res, handleSaveError(cfg, err) + } + + res.stack = newStack + cfg.Successf("Local stack replaced with the remote version") + return res, nil +} + +// nearestBranchAfterReplace decides which branch to check out after the local +// stack has been replaced with the remote version. If currentBranch still +// exists in newStack (or the user was on the trunk / a non-stack branch), it is +// returned unchanged. Otherwise it delegates to stack.NearestSurvivingBranch to +// pick the nearest branch from the pre-replacement ordering (neighbor above, +// then below), falling back to the top of the new stack, or the trunk if the new +// stack has no branches. Mirrors the checkout behavior of `gh stack modify`. +func nearestBranchAfterReplace(oldBranches []string, currentBranch string, newStack *stack.Stack) string { + // Still in the new stack, or never a stack branch (e.g. the trunk): stay put. + if newStack.IndexOf(currentBranch) >= 0 || slices.Index(oldBranches, currentBranch) < 0 { + return currentBranch + } + + if nearest := stack.NearestSurvivingBranch(oldBranches, currentBranch, func(name string) bool { + return newStack.IndexOf(name) >= 0 + }); nearest != "" { + return nearest + } + + // A dropped stack branch with no surviving neighbor — fall back to the top + // of the new stack, then the trunk. + if len(newStack.Branches) > 0 { + return newStack.Branches[len(newStack.Branches)-1].Branch + } + return newStack.Trunk.Branch +} + +// resolveDivergenceDeleteRemote deletes the diverged stack object on GitHub and +// removes the local association (clearing the stack ID). The PRs and local +// branches are left untouched — only the stack grouping on GitHub is removed. It +// stops the sync and points the user at `gh stack submit` to recreate the stack +// (which, unlike sync, also creates PRs for any un-submitted branches). +func resolveDivergenceDeleteRemote(cfg *config.Config, client github.ClientOps, sf *stack.StackFile, s *stack.Stack, gitDir string) (remoteReconcileResult, error) { + res := remoteReconcileResult{stop: true} + + number, err := ensureStackNumber(client, s) + if err != nil || number == 0 { + cfg.Warningf("Remote stack already deleted") + } else if _, dissolved, unstackErr := client.Unstack(number); unstackErr != nil { + var httpErr *api.HTTPError + if errors.As(unstackErr, &httpErr) && httpErr.StatusCode == 404 { + cfg.Warningf("Remote stack already deleted") + } else { + cfg.Errorf("failed to delete remote stack: %v", unstackErr) + return res, ErrAPIFailure + } + } else if dissolved { + cfg.Successf("Deleted the stack on GitHub") + } else { + cfg.Warningf("Some pull requests could not be unstacked and remain on GitHub") + } + + s.ID = "" + s.Number = 0 + if err := stack.Save(gitDir, sf); err != nil { + return res, handleSaveError(cfg, err) + } + + cfg.Printf("") + cfg.Printf("Your PRs and local branches are unchanged — only the stack on GitHub was removed.") + cfg.Printf(" Run `%s` to recreate the stack on GitHub.", cfg.ColorCyan("gh stack submit")) + cfg.Printf(" Run `%s` first if you want to change the stack's structure.", cfg.ColorCyan("gh stack modify")) + return res, nil +} diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 75f38c4d..feb89fc9 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -540,6 +540,43 @@ func TestSyncStackPRs_RemoteStack_UsesStackAPI(t *testing.T) { assert.True(t, s.Branches[1].PullRequest.Merged) } +func TestSyncStackPRs_BackfillsStackNumber(t *testing.T) { + // A stack tracked before the number was recorded (Number == 0) gets its + // number backfilled from the remote during the shared sync, so callers can + // display it. + s := &stack.Stack{ + ID: "100", // legacy: Number unset + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + } + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{ + {ID: 100, Number: 5, PullRequests: []int{10, 11}}, + }, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + switch number { + case 10: + return &github.PullRequest{Number: 10, HeadRefName: "b1", State: "OPEN"}, nil + case 11: + return &github.PullRequest{Number: 11, HeadRefName: "b2", State: "OPEN"}, nil + } + return nil, nil + }, + } + + _ = syncStackPRs(cfg, s) + collectOutput(cfg, outR, errR) + + assert.Equal(t, 5, s.Number, "the stack number should be backfilled from the remote") +} + func TestSyncStackPRs_RemoteStack_ClosedPRStaysAssociated(t *testing.T) { // When using the stack API, a closed (not merged) PR should remain // associated — the stack API is the source of truth, not PR state. @@ -691,39 +728,21 @@ func TestStackNeedsRebase_SkipsMergedBranches(t *testing.T) { assert.False(t, stackNeedsRebase(s), "should skip merged branches and find stack up to date") } -// setTestTokenForHost sets cfg.TokenForHostFn to return the given token for -// any host. Also sets RepoOverride so tests don't depend on real git context. -func setTestTokenForHost(cfg *config.Config, token string) { - cfg.TokenForHostFn = func(string) (string, string) { return token, "test" } +// setTestRepo sets RepoOverride so tests don't depend on real git context. +func setTestRepo(cfg *config.Config) { cfg.RepoOverride = &repository.Repository{Host: "github.com", Owner: "o", Name: "r"} } -func TestWarnStacksUnavailableOrPAT_ShowsPATMessage(t *testing.T) { - cfg, _, errR := config.NewTestConfig() - setTestTokenForHost(cfg, "github_pat_fine_grained") - - warnStacksUnavailableOrPAT(cfg) - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.Contains(t, output, "Personal access tokens are not supported by gh stack") - assert.NotContains(t, output, "Stacked PRs are not enabled") -} - -func TestWarnStacksUnavailableOrPAT_ShowsNotEnabledForOAuth(t *testing.T) { +func TestWarnStacksUnavailable_ShowsNotEnabled(t *testing.T) { cfg, _, errR := config.NewTestConfig() - setTestTokenForHost(cfg, "gho_oauth_token") - warnStacksUnavailableOrPAT(cfg) + warnStacksUnavailable(cfg) cfg.Err.Close() errOut, _ := io.ReadAll(errR) output := string(errOut) assert.Contains(t, output, "Stacked PRs are not enabled for this repository") - assert.NotContains(t, output, "Personal access tokens") } func TestEnsureLocalTrunk_AlreadyExists(t *testing.T) { diff --git a/cmd/view.go b/cmd/view.go index 2613f05d..057de60f 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -144,6 +144,10 @@ func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { repoName = repo.Name } + if s.Number > 0 { + cfg.Outf("%s\n", cfg.ColorBold(fmt.Sprintf("Stack #%d", s.Number))) + } + for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] merged := b.IsMerged() @@ -204,7 +208,6 @@ func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef // JSON output types for gh stack view --json. type viewJSONOutput struct { Trunk string `json:"trunk"` - Prefix string `json:"prefix,omitempty"` CurrentBranch string `json:"currentBranch"` Branches []viewJSONBranch `json:"branches"` } @@ -229,7 +232,6 @@ type viewJSONPR struct { func viewJSON(cfg *config.Config, s *stack.Stack, currentBranch string) error { out := viewJSONOutput{ Trunk: s.Trunk.Branch, - Prefix: s.Prefix, CurrentBranch: currentBranch, Branches: make([]viewJSONBranch, 0, len(s.Branches)), } @@ -311,7 +313,7 @@ func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string, prDet reversed[len(nodes)-1-i] = n } - model := stackview.New(reversed, s.Trunk, Version) + model := stackview.New(reversed, s.Trunk, Version, s.Number) p := tea.NewProgram( model, @@ -351,6 +353,10 @@ func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) er var buf bytes.Buffer + if s.Number > 0 { + fmt.Fprintf(&buf, "%s\n\n", cfg.ColorBold(fmt.Sprintf("Stack #%d", s.Number))) + } + for i := len(s.Branches) - 1; i >= 0; i-- { b := s.Branches[i] diff --git a/cmd/view_test.go b/cmd/view_test.go index 0e639c5b..cb228c48 100644 --- a/cmd/view_test.go +++ b/cmd/view_test.go @@ -58,8 +58,7 @@ func TestViewJSON(t *testing.T) { { name: "basic stack with PRs", stack: &stack.Stack{ - Prefix: "feat", - Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, + Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, Branches: []stack.BranchRef{ { Branch: "feat/01", @@ -151,8 +150,7 @@ func TestViewJSON_BranchFields(t *testing.T) { }) s := &stack.Stack{ - Prefix: "feat", - Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"}, + Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"}, Branches: []stack.BranchRef{ { Branch: "feat/01", @@ -182,8 +180,6 @@ func TestViewJSON_BranchFields(t *testing.T) { var got viewJSONOutput require.NoError(t, json.Unmarshal(raw, &got)) - assert.Equal(t, "feat", got.Prefix) - // First branch: merged b0 := got.Branches[0] assert.Equal(t, "feat/01", b0.Name) @@ -497,8 +493,7 @@ func TestRunViewJSON_MultipleStacks(t *testing.T) { func TestRunViewJSON_SingleStack(t *testing.T) { tmpDir := t.TempDir() writeStackFile(t, tmpDir, stack.Stack{ - Prefix: "feat", - Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, + Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, Branches: []stack.BranchRef{ { Branch: "feat/01", @@ -531,7 +526,6 @@ func TestRunViewJSON_SingleStack(t *testing.T) { var got viewJSONOutput require.NoError(t, json.Unmarshal(raw, &got), "output should be valid JSON: %s", string(raw)) assert.Equal(t, "main", got.Trunk) - assert.Equal(t, "feat", got.Prefix) assert.Len(t, got.Branches, 1) assert.Equal(t, "feat/01", got.Branches[0].Name) assert.True(t, got.Branches[0].IsCurrent) diff --git a/docs/package-lock.json b/docs/package-lock.json index 49aed497..8656976f 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -8,38 +8,211 @@ "name": "docs", "version": "0.0.1", "dependencies": { - "@astrojs/starlight": "^0.38.2", - "astro": "^6.3.1", + "@astrojs/starlight": "^0.41.1", + "astro": "^7.0.3", "sharp": "^0.34.5" } }, - "node_modules/@astrojs/compiler": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-4.0.0.tgz", - "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", - "license": "MIT" + "node_modules/@astrojs/compiler-binding": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz", + "integrity": "sha512-Xz3iBNse+hXXD25IXxsuXEt2ai8klAWE15CRm/EQBc9+aE3jXaF07DZx+iakk3HC6NHvWlEPzLPyxsLgPzOJsw==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.2.3", + "@astrojs/compiler-binding-darwin-x64": "0.2.3", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.2.3", + "@astrojs/compiler-binding-linux-arm64-musl": "0.2.3", + "@astrojs/compiler-binding-linux-x64-gnu": "0.2.3", + "@astrojs/compiler-binding-linux-x64-musl": "0.2.3", + "@astrojs/compiler-binding-wasm32-wasi": "0.2.3", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.2.3", + "@astrojs/compiler-binding-win32-x64-msvc": "0.2.3" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.2.3.tgz", + "integrity": "sha512-sJIHeL1ONXEBLob8ZaXfmX6iCftUno08G/cMXj2FJnL0xNbHuELcEq1mjxHVFHNgUYu4P7xJNm2mpc0zUEPoKw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.2.3.tgz", + "integrity": "sha512-P0NYu6aaIeLCqFfszxxBHL0a5WRaYigNVbDoO654Gi5Q2au5duDb5xZBv5EqUg4qnQVC173FXNvGZu1M7nk+/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.2.3.tgz", + "integrity": "sha512-PqVN5AqhuDqfx3ejaerwrC8codpV9jnyKV+IOel027qsJ1anFUJLdjUlY8VVys0xgd8lmqveX11OkcaQj/otTg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.2.3.tgz", + "integrity": "sha512-O3e2CbN4yTsRguWYNnRd0p5YQ0H3fb7KpcR0W4R319q/gq5B1pJ7eqNbiO3b8g2AuiEcRTiUz5jeGT9j69cxOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.2.3.tgz", + "integrity": "sha512-hbLBjXVp+96psMe7/7uqyrquGiULXANrq6REVxxPK/I5VzebZ7LHmSfykmByUbLyR1u+K6CTBKgvdQsK2L+2Xw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.2.3.tgz", + "integrity": "sha512-vIiEvOwrJfHZMaTmqUCrFTIwMYL0+PD3Rvy7kFDQgERyx3zhaw8CPa01MCCqa+/sj344BGrXKZ6ti37SgNLMhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.2.3.tgz", + "integrity": "sha512-p9S2X8z/mUR2SMzAVJRFMCt8YaalKR+pjl2DgpdjzCQc6ww4bo8kiy54tgKqxZeNF5c+/2tCDTQIxVSm9V1FsA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.2.3.tgz", + "integrity": "sha512-vcCG6JttIb5vbSmcxO2O398hpVj7lQ349iS7cjgYP6ZuLVEnw+9qPAr2MM2kJkU5wEGZqJ2gyi/M7UJoPwH1iQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.2.3.tgz", + "integrity": "sha512-hKssjNvC36e00Inb1GW1JsVyCFSCGnIjKem4S8q0VIW6cpWAUpvYB4qQU2HIDGD6SDX0ork4F5sWkNWkp2hrGQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.2.3.tgz", + "integrity": "sha512-JRAtRcPxS4JeAZEIQFQ6GecBs/Wyp4m6/E8vBNxSgVfo1AtRVLUqRCl5oCGOZ0X/BSBB3Vef/7IlzyiGKi2ORA==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.2.3" + } }, "node_modules/@astrojs/internal-helpers": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.8.0.tgz", - "integrity": "sha512-J56GrhEiV+4dmrGLPNOl2pZjpHXAndWVyiVDYGDuw6MWKpBSEMLdFxHzeM/6sqaknw9M+HFfHZAcvi3OfT3D/w==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz", + "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==", "license": "MIT", "dependencies": { - "picomatch": "^4.0.3" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" } }, "node_modules/@astrojs/markdown-remark": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.0.1.tgz", - "integrity": "sha512-zAfLJmn07u9SlDNNHTpjv0RT4F8D4k54NR7ReRas8CO4OeGoqSvOuKwqCFg2/cqN3wHwdWlK/7Yv/lMXlhVIaw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", + "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.8.0", - "@astrojs/prism": "4.0.1", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -47,8 +220,6 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -56,13 +227,26 @@ "vfile": "^6.0.3" } }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.2.tgz", + "integrity": "sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.9.1" + } + }, "node_modules/@astrojs/mdx": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.2.tgz", - "integrity": "sha512-0as6odPH9ZQhS3pdH9dWmVOwgXuDtytJiE4VvYgR0lSFBvF4PSTyE0HdODHm/d7dBghvWTPc2bQaBm4y4nTBNw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.0.tgz", + "integrity": "sha512-LKwNA8nnLtEM0auoP6OfH/UnlKe1Ub59qZjbcYkZjPBGw6PkJewWkA/1qwLpECvV6gMDd6TR6eqV9p/VYZrcrQ==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "7.0.1", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", @@ -80,13 +264,19 @@ "node": ">=22.12.0" }, "peerDependencies": { - "astro": "^6.0.0" + "@astrojs/markdown-satteri": "^0.3.1-alpha.0", + "astro": "^7.0.0-alpha.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-satteri": { + "optional": true + } } }, "node_modules/@astrojs/prism": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", - "integrity": "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" @@ -96,9 +286,9 @@ } }, "node_modules/@astrojs/sitemap": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.1.tgz", - "integrity": "sha512-IzQqdTeskaMX+QDZCzMuJIp8A8C1vgzMBp/NmHNnadepHYNHcxQdGLQZYfkbd2EbRXUfOS+UDIKx8sKg0oWVdw==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz", + "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==", "license": "MIT", "dependencies": { "sitemap": "^9.0.0", @@ -107,42 +297,49 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.38.2", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.38.2.tgz", - "integrity": "sha512-7AsrvG4EsXUmJT5uqiXJN4oZqKaY0wc/Ip7C6/zGnShHRVoTAA4jxeYIZ3wqbqA6zv4cnp9qk31vB2m2dUcmfg==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.1.tgz", + "integrity": "sha512-avf2OmrVg6GdVU18juebjjIIuLa+uS3syHuJ/3yDaEFP/8it+YvcxRrYDSf7K6rC4v770UxIddba2hAqQyTeYA==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "^7.0.0", - "@astrojs/mdx": "^5.0.0", - "@astrojs/sitemap": "^3.7.1", + "@astrojs/markdown-satteri": "^0.3.2", + "@astrojs/mdx": "^7.0.0", + "@astrojs/sitemap": "^3.7.2", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", - "astro-expressive-code": "^0.41.6", + "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", - "hast-util-from-html": "^2.0.1", - "hast-util-select": "^6.0.2", - "hast-util-to-string": "^3.0.0", - "hastscript": "^9.0.0", - "i18next": "^23.11.5", - "js-yaml": "^4.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-select": "^6.0.4", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", "klona": "^2.0.6", - "magic-string": "^0.30.17", - "mdast-util-directive": "^3.0.0", - "mdast-util-to-markdown": "^2.1.0", + "magic-string": "^0.30.21", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", - "pagefind": "^1.3.0", - "rehype": "^13.0.1", - "rehype-format": "^5.0.0", - "remark-directive": "^3.0.0", + "pagefind": "^1.5.2", + "rehype": "^13.0.2", + "rehype-format": "^5.0.1", + "remark-directive": "^4.0.0", + "satteri": "^0.9.1", "ultrahtml": "^1.6.0", "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.2" + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" }, "peerDependencies": { - "astro": "^6.0.0" + "@astrojs/markdown-remark": "^7.2.0", + "astro": "^7.0.2" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } } }, "node_modules/@astrojs/telemetry": { @@ -194,15 +391,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -216,6 +404,119 @@ "node": ">=6.9.0" } }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.4.tgz", + "integrity": "sha512-W3MSUkr2mZRR8Stoe+lqNAyzQzRuFMU8WffV9IvFSxTok0LGWR0ZZQPLELU4QTRiUbhL2Y4VUP9vV7pj8rHjgg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.9.4.tgz", + "integrity": "sha512-DXOuuaE1lsv7mpk2mOvGrzqoEWEvOIZEO/fXVa7zfM23Iob+CBjBkRAMwpHA4pmZ3j6Gj7WJzPKw0kQ7w741AQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.9.4.tgz", + "integrity": "sha512-gJxU9rGGoqIznSEgEzpjxkry24jeHuMpoo1tCIAhHYh7WaD3j5F8zt3jmHxEaN1Uwa+K5+wFgIR2uIGOnMzEmw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.9.4.tgz", + "integrity": "sha512-Wjzu9hmmAbfmDkBfPI1VdZygJtYz9uYZQnkEyrXi6S2JFi+2pXQ1A5irj38bqm0IZmWcTbk0cVG4NZnPdtVNJA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.9.4.tgz", + "integrity": "sha512-MR1Q+wMx65FQlbSV7cRqWW87Knp0zkoaIV55Dt+xZl028wJABXEPEEmG3670SLq7lVZvcGIDwCgSg2kCYxvRwA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.9.4.tgz", + "integrity": "sha512-T4gxhXve3zyNAZesrXAd/rDZOGRkbfFIUFld4TGsw6BsjoIteCcDji6IMqeXyaWEVSykY2X8Eid2hr6aXGYAaw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.9.4.tgz", + "integrity": "sha512-/CEG8LUlpaBEnhFnYVn0UnlHFLs51UhrkJBUPDUXLzkadzAcnR88iRA/nOl7Zwhjb4WhfBV4p3P5qeOJMtH0iA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.9.4.tgz", + "integrity": "sha512-E1ZPQbgCtFKiU7pFYVndynvY7ne4coeVDUgnVThErSFlJ2ceQCBZrfRTD1lzrIDy63Bbqo+g/cZY9duw+JYjIw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.9.4.tgz", + "integrity": "sha512-5I7SiarsNdAUuhJb50CXJPTwr/ECVrBoU+fymoLjChK5fW//+srhY4lstcNTzgFRtQSYfVtm4OQZz16CVMeTeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", @@ -256,10 +557,31 @@ "node": ">=14" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -267,9 +589,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -283,9 +605,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -299,9 +621,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -315,9 +637,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -331,9 +653,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -347,9 +669,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -363,9 +685,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -379,9 +701,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -395,9 +717,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -411,9 +733,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -427,9 +749,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -443,9 +765,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -459,9 +781,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -475,9 +797,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -491,9 +813,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -507,9 +829,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -523,9 +845,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -539,9 +861,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -555,9 +877,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -571,9 +893,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -587,9 +909,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -603,9 +925,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -619,9 +941,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -635,9 +957,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -651,9 +973,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -667,9 +989,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -683,9 +1005,9 @@ } }, "node_modules/@expressive-code/core": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.41.7.tgz", - "integrity": "sha512-ck92uZYZ9Wba2zxkiZLsZGi9N54pMSAVdrI9uW3Oo9AtLglD5RmrdTwbYPCT2S/jC36JGB2i+pnQtBm/Ib2+dg==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.0.tgz", + "integrity": "sha512-xgiF2P6tYUbrhi3+x0S8xHZWT1t3Bvb3U91tAtRbLb9HLejLvYc5GZUqKICKLaUN4iSGhhNJu2fM/aH8e5yCMg==", "license": "MIT", "dependencies": { "@ctrl/tinycolor": "^4.0.4", @@ -700,108 +1022,31 @@ } }, "node_modules/@expressive-code/plugin-frames": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.41.7.tgz", - "integrity": "sha512-diKtxjQw/979cTglRFaMCY/sR6hWF0kSMg8jsKLXaZBSfGS0I/Hoe7Qds3vVEgeoW+GHHQzMcwvgx/MOIXhrTA==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.0.tgz", + "integrity": "sha512-V6M6+zVc1GzqCvXkQHc2m5rcFOIVzJgMq5gnfrMnVf2gwtj/sg4H93c1f/mGeqHycubwkHFUDyParAOiGeDZeA==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7" + "@expressive-code/core": "^0.44.0" } }, "node_modules/@expressive-code/plugin-shiki": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.41.7.tgz", - "integrity": "sha512-DL605bLrUOgqTdZ0Ot5MlTaWzppRkzzqzeGEu7ODnHF39IkEBbFdsC7pbl3LbUQ1DFtnfx6rD54k/cdofbW6KQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.0.tgz", + "integrity": "sha512-RZsdaqlbGqyAQKuoX4myQXxjmiE2l5KBpJ/gKPh62tCdIdpWyjbzVqSo8+5XsezZxkfi8AJ/J6EUaBTPROFX/Q==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7", - "shiki": "^3.2.2" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@expressive-code/plugin-shiki/node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@expressive-code/core": "^0.44.0", + "shiki": "^4.0.2" } }, "node_modules/@expressive-code/plugin-text-markers": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.41.7.tgz", - "integrity": "sha512-Ewpwuc5t6eFdZmWlFyeuy3e1PTQC0jFvw2Q+2bpcWXbOZhPLsT7+h8lsSIJxb5mS7wZko7cKyQ2RLYDyK6Fpmw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.0.tgz", + "integrity": "sha512-0/m3A5b+lz2upyNq+wzZ1S69HRoJmyFs5LsR42lVZ9pmGRlBiSBYQpvqlji4DBj1+Riamxc0AvcCr5kuzOQeWA==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7" + "@expressive-code/core": "^0.44.0" } }, "node_modules/@img/colour": { @@ -1312,16 +1557,43 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oslojs/encoding": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pagefind/darwin-arm64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.4.0.tgz", - "integrity": "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", "cpu": [ "arm64" ], @@ -1332,9 +1604,9 @@ ] }, "node_modules/@pagefind/darwin-x64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.4.0.tgz", - "integrity": "sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", "cpu": [ "x64" ], @@ -1351,9 +1623,9 @@ "license": "MIT" }, "node_modules/@pagefind/freebsd-x64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.4.0.tgz", - "integrity": "sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", "cpu": [ "x64" ], @@ -1364,9 +1636,9 @@ ] }, "node_modules/@pagefind/linux-arm64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.4.0.tgz", - "integrity": "sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", "cpu": [ "arm64" ], @@ -1377,9 +1649,9 @@ ] }, "node_modules/@pagefind/linux-x64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.4.0.tgz", - "integrity": "sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", "cpu": [ "x64" ], @@ -1389,12 +1661,12 @@ "linux" ] }, - "node_modules/@pagefind/windows-x64": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.4.0.tgz", - "integrity": "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==", + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, @@ -1402,51 +1674,23 @@ "win32" ] }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", "cpu": [ - "arm" + "x64" ], "license": "MIT", "optional": true, "os": [ - "android" + "win32" ] }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -1454,12 +1698,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -1467,12 +1714,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -1480,25 +1730,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -1506,25 +1746,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -1532,25 +1762,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -1558,51 +1778,31 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ - "loong64" + "arm64" ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -1610,38 +1810,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -1649,12 +1826,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -1662,12 +1842,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -1675,25 +1858,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -1701,38 +1874,49 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ - "arm64" + "wasm32" ], "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ - "ia32" + "arm64" ], "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -1740,29 +1924,53 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", - "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.0.tgz", + "integrity": "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/primitive": "4.3.0", + "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" @@ -1772,26 +1980,26 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", - "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.0.tgz", + "integrity": "sha512-hTv/KiFf2tpiqlACPiztGGurEARWIutB8YUhcrA1pUC7VzzwKO+g5crUocrLztrZ5ro5Z4hbXg7bYclETn3gSQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.0.tgz", + "integrity": "sha512-1vMdN3gHfnKfLYwecUI2ITJI4RhHt96xEaJumVn7Heb0IlJ8WQMIH0Voak+2j22BpSNKdnOfB/pCTPnPm2gq7A==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -1799,24 +2007,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.0.tgz", + "integrity": "sha512-rnlqFbBRSys9bT4gl/5rw9RnS0W/I84ZldXPkO7cvlEMoV85TyF/aU01N7/NbSR776RNLjrJKjfFUXJR6wN1Cg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.3.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", - "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.0.tgz", + "integrity": "sha512-CPkz64PTa5diRW1ggzMZH9VM/du4RNChYgVtgqrFcgruvIybmCvySv8GkiHSczUHXYuuR8TdKEwFx+UnZMpgdg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -1825,21 +2033,21 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.0.tgz", + "integrity": "sha512-Avgt05YiT+Y3prjIc9lmQxhJzHBcCfR6cjiFW4OyaMBbt2A6trX5rfjUzx+Vj/mE9qpArYjatnqo9XPjQNW/AQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.3.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", - "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.0.tgz", + "integrity": "sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -1855,6 +2063,16 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1904,9 +2122,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", "license": "MIT" }, "node_modules/@types/ms": { @@ -1925,12 +2143,12 @@ } }, "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/sax": { @@ -1955,9 +2173,9 @@ "license": "ISC" }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1975,6 +2193,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2041,30 +2271,31 @@ } }, "node_modules/astro": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.3.1.tgz", - "integrity": "sha512-atz6dmkE3Gu24bDgb7g2RE/BYnKqPYIHd6hTUM1UXvu/i7qNZOKLAqEHvgYpv9PQVcgWsXpk4/OOXZ0E/FzvSQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.0.3.tgz", + "integrity": "sha512-CK+G+Tl2DMV1EXCwVG45vyurxf2IfRTklMxDhRKn+tst9Yl8rWXpudL62Fa6zin5Bt968FBvuyASj1aJShROZg==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^4.0.0", - "@astrojs/internal-helpers": "0.9.0", - "@astrojs/markdown-remark": "7.1.1", + "@astrojs/compiler-rs": "^0.2.2", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-satteri": "0.3.2", "@astrojs/telemetry": "3.3.2", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", + "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", - "devalue": "^5.6.3", + "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", - "esbuild": "^0.27.3", + "esbuild": "^0.28.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", @@ -2096,7 +2327,7 @@ "unist-util-visit": "^5.1.0", "unstorage": "^1.17.5", "vfile": "^6.0.3", - "vite": "^7.3.2", + "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", @@ -2116,56 +2347,27 @@ }, "optionalDependencies": { "sharp": "^0.34.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "7.2.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } } }, "node_modules/astro-expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.41.7.tgz", - "integrity": "sha512-hUpogGc6DdAd+I7pPXsctyYPRBJDK7Q7d06s4cyP0Vz3OcbziP3FNzN0jZci1BpCvLn9675DvS7B9ctKKX64JQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.0.tgz", + "integrity": "sha512-b1wN/ZvbJprzxlGKIpIes2kQrCY5KRLwys2tWbZAZyjGZcW5ZtgneZnBwzNRiBna9/48d4mQl19KLjcRuhO1hw==", "license": "MIT", "dependencies": { - "rehype-expressive-code": "^0.41.7" + "rehype-expressive-code": "^0.44.0", + "url-extras": "^0.1.0" }, "peerDependencies": { - "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta" - } - }, - "node_modules/astro/node_modules/@astrojs/internal-helpers": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.0.tgz", - "integrity": "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==", - "license": "MIT", - "dependencies": { - "picomatch": "^4.0.4" - } - }, - "node_modules/astro/node_modules/@astrojs/markdown-remark": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.1.tgz", - "integrity": "sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==", - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.9.0", - "@astrojs/prism": "4.0.1", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, "node_modules/axobject-query": { @@ -2703,9 +2905,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2715,32 +2917,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-string-regexp": { @@ -2853,15 +3055,15 @@ "license": "MIT" }, "node_modules/expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.7.tgz", - "integrity": "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.0.tgz", + "integrity": "sha512-JXVWVNCKlLuZLMQH8cOiDUSosT0Bb+elwE/dbAkpwFwDFmyFyWlECoWZIohh2FkIF1iI67TQJ+Ts9k7oNDh2qA==", "license": "MIT", "dependencies": { - "@expressive-code/core": "^0.41.7", - "@expressive-code/plugin-frames": "^0.41.7", - "@expressive-code/plugin-shiki": "^0.41.7", - "@expressive-code/plugin-text-markers": "^0.41.7" + "@expressive-code/core": "^0.44.0", + "@expressive-code/plugin-frames": "^0.44.0", + "@expressive-code/plugin-shiki": "^0.44.0", + "@expressive-code/plugin-text-markers": "^0.44.0" } }, "node_modules/extend": { @@ -3367,26 +3569,31 @@ "license": "BSD-2-Clause" }, "node_modules/i18next": { - "version": "23.16.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", - "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "version": "26.3.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz", + "integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==", "funding": [ { "type": "individual", - "url": "https://locize.com" + "url": "https://www.locize.com/i18next" }, { "type": "individual", - "url": "https://locize.com/i18next.html" + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" }, { "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + "url": "https://www.locize.com" } ], "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2" + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/inline-style-parser": { @@ -3560,6 +3767,255 @@ "node": ">= 8" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -4020,9 +4476,9 @@ } }, "node_modules/micromark-extension-directive": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.2.tgz", - "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -4702,9 +5158,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -4802,18 +5258,18 @@ "license": "MIT" }, "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", - "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "license": "MIT", "dependencies": { - "oniguruma-parser": "^0.12.1", + "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } @@ -4868,20 +5324,21 @@ "license": "MIT" }, "node_modules/pagefind": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.4.0.tgz", - "integrity": "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", "license": "MIT", "bin": { "pagefind": "lib/runner/bin.cjs" }, "optionalDependencies": { - "@pagefind/darwin-arm64": "1.4.0", - "@pagefind/darwin-x64": "1.4.0", - "@pagefind/freebsd-x64": "1.4.0", - "@pagefind/linux-arm64": "1.4.0", - "@pagefind/linux-x64": "1.4.0", - "@pagefind/windows-x64": "1.4.0" + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" } }, "node_modules/parse-entities": { @@ -4964,9 +5421,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -4983,7 +5440,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5017,9 +5474,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5038,6 +5495,15 @@ "node": ">=6" } }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5175,12 +5641,12 @@ } }, "node_modules/rehype-expressive-code": { - "version": "0.41.7", - "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.7.tgz", - "integrity": "sha512-25f8ZMSF1d9CMscX7Cft0TSQIqdwjce2gDOvQ+d/w0FovsMwrSt3ODP4P3Z7wO1jsIJ4eYyaDRnIR/27bd/EMQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.0.tgz", + "integrity": "sha512-5r74C5F2sMR3X+QJH8OKWgZBO/cqRw5W1fLT6GVlSfLqepk+4j8tGFkyPqZYWjwntsBHzKPDH2zI68sZ7ScLfA==", "license": "MIT", "dependencies": { - "expressive-code": "^0.41.7" + "expressive-code": "^0.44.0" } }, "node_modules/rehype-format": { @@ -5258,14 +5724,14 @@ } }, "node_modules/remark-directive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", - "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", - "micromark-extension-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", "unified": "^11.0.0" }, "funding": { @@ -5438,48 +5904,59 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/satteri": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.4.tgz", + "integrity": "sha512-BKob126Tay84diOZsnVNH/Q/c+3njPJTCad3w5zLKa6j8bVjxskPNHDtxrMwYK4bN/RlqUSdMnPwKY4k65EMOQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" + "@bruits/satteri-darwin-arm64": "0.9.4", + "@bruits/satteri-darwin-x64": "0.9.4", + "@bruits/satteri-linux-arm64-gnu": "0.9.4", + "@bruits/satteri-linux-arm64-musl": "0.9.4", + "@bruits/satteri-linux-x64-gnu": "0.9.4", + "@bruits/satteri-linux-x64-musl": "0.9.4", + "@bruits/satteri-wasm32-wasi": "0.9.4", + "@bruits/satteri-win32-arm64-msvc": "0.9.4", + "@bruits/satteri-win32-x64-msvc": "0.9.4" } }, "node_modules/sax": { @@ -5548,17 +6025,17 @@ } }, "node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.0.tgz", + "integrity": "sha512-NKKjWzR6LIGL3sXBrWDw9sDS9cxx42/DkysaNqJEeOWE8Kix5gpak0bc00OfDVEO4oyXSyz8+aRaqKoBD1yo7A==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/core": "4.3.0", + "@shikijs/engine-javascript": "4.3.0", + "@shikijs/engine-oniguruma": "4.3.0", + "@shikijs/langs": "4.3.0", + "@shikijs/themes": "4.3.0", + "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -5592,9 +6069,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -5719,13 +6196,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5780,9 +6257,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unified": { @@ -6047,6 +6524,18 @@ } } }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6096,17 +6585,16 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6122,9 +6610,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -6137,13 +6626,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { diff --git a/docs/package.json b/docs/package.json index 48879de9..ebee0797 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,8 +10,8 @@ "astro": "astro" }, "dependencies": { - "@astrojs/starlight": "^0.38.2", - "astro": "^6.3.1", + "@astrojs/starlight": "^0.41.1", + "astro": "^7.0.3", "sharp": "^0.34.5" } } diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index fc18c1e0..890c5208 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -50,7 +50,7 @@ gh stack init db-migrations api-routes frontend ### How do I delete my stack? -**From the CLI** — Run `gh stack unstack` (or `gh stack delete`) to delete the stack on GitHub and remove local tracking. Use `--local` to only remove local tracking. +**From the CLI** — Run `gh stack unstack` (or `gh stack delete`) to delete the stack on GitHub and remove local tracking. You can also unstack any stack by its number from anywhere in the repository — `gh stack unstack 7` — whether or not it's checked out locally. Use `--local` to only remove local tracking. **From the UI** — You can unstack PRs from the GitHub UI — see [Unstacking](/gh-stack/guides/ui/#unstacking) for a walkthrough. This dissolves the association between PRs, turning them back into standard independent PRs. @@ -255,7 +255,14 @@ This doesn't create any local tracking and only hits the APIs to create Stacked If the provided branches already have open PRs, `link` will use them. If not, it creates draft PRs by default with the correct base branch chaining. -To add more to the stack, run `link` again, but be sure to include the full list of PRs/branches in the stack: +To add more to the stack, pass the stack number (shown in the GitHub stack UI) as the first argument, followed by just the new PRs or branches — you no longer need to re-list the PRs already in the stack: + +```bash +# 42 is the stack number; change4 and change5 are appended to its top +gh stack link 42 change4 change5 +``` + +You can also pass the full list of PRs/branches (without a leading stack number) to create a stack or additively update an existing one: ```bash gh stack link 123 124 125 change4 change5 diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 340856ac..4c942d47 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -19,10 +19,6 @@ Stacked PRs is currently in private preview. This feature will **not work** unle gh extension install github/gh-stack ``` -:::note[Authentication] -The `gh stack` CLI requires OAuth authentication via `gh auth login`. Personal access tokens (PATs) are not supported. -::: - ## Set Up AI Agent Integration If you use AI coding agents (like GitHub Copilot), install the gh-stack skill so they know how to work with Stacked PRs: diff --git a/docs/src/content/docs/guides/stacked-prs.md b/docs/src/content/docs/guides/stacked-prs.md index 23fe269b..4336ce44 100644 --- a/docs/src/content/docs/guides/stacked-prs.md +++ b/docs/src/content/docs/guides/stacked-prs.md @@ -51,4 +51,4 @@ gh stack sync - **`gh stack push`** pushes branches only (uses `--force-with-lease` for safety). It does not create or update PRs. - **`gh stack submit`** pushes branches and creates or updates PRs, linking them as a Stack on GitHub. -- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync PR state, link open PRs into a Stack on GitHub, and optionally prune local branches for merged PRs. +- **`gh stack sync`** is the all-in-one command: fetch, rebase, push, sync stack/PR state, link open PRs into a Stack on GitHub, and optionally prune local branches for merged PRs. If there is a divergence between local and remote stacks, you will be prompted to resolve. diff --git a/docs/src/content/docs/guides/workflows.md b/docs/src/content/docs/guides/workflows.md index 65cd0bec..a102281e 100644 --- a/docs/src/content/docs/guides/workflows.md +++ b/docs/src/content/docs/guides/workflows.md @@ -39,40 +39,40 @@ gh stack sync ## Abbreviated Workflow -For speed, use a branch prefix with `--numbered` and the `-Am` flags to fold staging, committing, and branch creation into a single command. Branch names are auto-generated as `prefix/01`, `prefix/02`, etc. +For speed, use the `-Am` flags to fold staging, committing, and branch creation into a single command. When you don't pass a branch name, one is auto-generated from the commit message in date+slug format (e.g., `03-24-auth_middleware`). ```sh # Alias `gh stack` as `gs` for easier use gh stack alias -# 1. Start a stack with numbered branches -gs init -p feat --numbered -# → creates feat/01 and checks it out +# 1. Start a stack +gs init auth +# → creates auth and checks it out # 2. Write code for the first layer # ... write code ... # 3. Stage and commit on the current branch gs add -Am "Auth middleware" -# → feat/01 has no commits yet, so the commit lands here +# → auth has no commits yet, so the commit lands here # 4. Write code for the next layer # ... write code ... # 5. Create the next branch and commit gs add -Am "API routes" -# → feat/01 already has commits, so feat/02 is created +# → auth already has commits, so a new branch is created # 6. Keep going # ... write code ... gs add -Am "Frontend components" -# → creates feat/03 +# → creates another branch # 7. Push everything and create PRs gs submit ``` -Each `gs add -Am "..."` stages all files, commits, and (if the current branch already has commits) creates a new branch — no separate `git add` or `git commit` needed. +Each `gs add -Am "..."` stages all files, commits, and (if the current branch already has commits) creates a new branch — no separate `git add` or `git commit` needed. Pass an explicit branch name any time you want to control it: `gs add -Am "API routes" api-routes`. ## Making Mid-Stack Changes @@ -130,15 +130,28 @@ gh stack sync This command: 1. Fetches the latest changes from the remote -2. Fast-forwards the trunk branch -3. Rebases all remaining stack branches onto the updated trunk -4. Pushes the updated branches -5. Syncs PR state from GitHub -6. Links the open PRs into a Stack on GitHub (creating or updating the remote stack when two or more PRs exist) -7. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) +2. Reconciles the remote stack with your local stack +3. Fast-forwards the trunk branch +4. Rebases all remaining stack branches onto the updated trunk +5. Pushes the updated branches +6. Syncs PR state from GitHub +7. Links the open PRs into a Stack on GitHub (creating or updating the remote stack when two or more PRs exist) +8. Prompts to prune local branches for merged PRs (use `--prune` to prune automatically) If a conflict is detected during the rebase, all branches are restored to their original state, and you're advised to run `gh stack rebase` to resolve conflicts interactively. +### Pulling in PRs added to the stack on GitHub + +If PRs are added to the stack on GitHub by someone else, `gh stack sync` fetches the new PRs' branches and appends them to your local stack so it mirrors the remote. + +If your local and remote stacks have diverged — for example, you added a branch locally while different PRs/branches were added to the same stack on GitHub — sync can't merge them automatically. In an interactive terminal it offers three choices: + +- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes. +- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet. +- **Cancel** — aborts the sync without pushing branches or updating any PRs. + +In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack. + ## Rebasing Your Stack Stacked PRs rely on rebasing rather than merge commits to keep each branch's diff clean and reviewable. If you're coming from a merge-commit workflow, the key difference is: instead of merging upstream changes into your branch (which creates a merge commit with multiple parents), you replay your commits on top of the latest base. The result is a linear history where each PR shows only its specific changes. diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index f3cb83a6..eeec98ab 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -88,10 +88,10 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han - **Pushing branches** — `gh stack push` pushes all branches to the remote. - **Creating PRs** — `gh stack submit` pushes branches and creates or updates PRs, linking them as a Stack on GitHub. - **Navigating the stack** — `gh stack up`, `down`, `top`, and `bottom` let you move between layers without remembering branch names. -- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, updates PR state, and links open PRs into a Stack on GitHub in one command. +- **Syncing everything** — `gh stack sync` fetches, rebases, pushes, updates PR state, and links open PRs into a Stack on GitHub in one command. It also syncs the stack's remote state, pulling down branches for any PRs added to the stack on GitHub. - **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack. - **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking. -- **Checking out a stack** — `gh stack checkout ` pulls down a stack, with all its branches, from GitHub to your local machine. +- **Checking out a stack** — `gh stack checkout` pulls a stack, with all its branches, down from GitHub to your local machine. Give it a stack number or run it with no arguments to pick from an interactive list of every stack available to you (local and remote). The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 3cd99de9..b34556f7 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -12,7 +12,7 @@ gh extension install github/gh-stack Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. :::note[Authentication] -The `gh stack` CLI requires OAuth authentication via `gh auth login`. Personal access tokens (PATs) are not supported. +The `gh stack` CLI uses your GitHub CLI authentication — run `gh auth login` if you haven't already. ::: --- @@ -27,19 +27,15 @@ Initialize a new stack in the current repository. gh stack init [flags] [branches...] ``` -Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. If a branch name contains slashes (e.g., `feat/api`), prompts if you would like to use a prefix (e.g., `feat/`) for all branches in the stack. +Initializes a new stack locally. In interactive mode (no arguments), prompts for a branch name and offers to use the current branch as the first layer. 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`. -Use `--numbered` with `--prefix` to enable auto-incrementing branch names (`prefix/01`, `prefix/02`, …). - Enables `git rerere` automatically so that conflict resolutions are remembered across rebases. | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch for the stack (defaults to the repository's default branch) | -| `-p, --prefix ` | Set a branch name prefix for the stack | -| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | **Examples:** @@ -55,14 +51,6 @@ gh stack init --base develop feature-auth # Adopt or create multiple branches at once gh stack init feature-auth feature-api feature-ui - -# Set a prefix — prompts for a branch name suffix -gh stack init -p feat -# → type "auth" → creates feat/auth - -# Use numbered auto-incrementing branch names -gh stack init -p feat --numbered -# → creates feat/01 automatically ``` ### `gh stack add` @@ -75,7 +63,7 @@ gh stack add [flags] [branch] 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. -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. If the stack was created with `--numbered`, auto-generated names use numbered format (`prefix/01`, `prefix/02`); otherwise, date+slug format is 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`). | Flag | Description | |------|-------------| @@ -135,21 +123,26 @@ gh stack view --json ### `gh stack checkout` -Check out a stack from a pull request number, URL, or branch name. +Check out a stack by its stack number, a pull request number, a PR URL, or a branch name. ```sh -gh stack checkout [ | | ] +gh stack checkout [ | | | ] ``` -When a PR number or URL is provided (e.g., `123` or `https://github.com/owner/repo/pull/123`), 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. +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. When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, closed, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** ```sh +# Check out a stack by its stack number +gh stack checkout 7 + # Check out a stack by PR number gh stack checkout 42 @@ -159,7 +152,7 @@ gh stack checkout https://github.com/owner/repo/pull/42 # Check out a stack by branch name (local only) gh stack checkout feature-auth -# Interactive — select from locally tracked stacks +# Interactive — pick from all available stacks (local and remote) gh stack checkout ``` @@ -229,28 +222,33 @@ gh stack modify --abort ### `gh stack unstack` -Remove a stack from local tracking and delete it on GitHub. Also available as `gh stack delete`. +Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`. ```sh -gh stack unstack [flags] +gh stack unstack [] [flags] ``` -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one that contains the currently checked out branch — unstacking it on GitHub and removing local tracking. + +Provide a stack number (the identifier shown in the github.com stack UI) to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the stack is unstacked directly through the GitHub API. When the stack is also available locally, its local tracking is removed as well. -Deletes the stack on GitHub first, if it exists, then removes it from local tracking. If the remote deletion fails, the local state is left untouched so you can retry. Use `--local` to skip the remote deletion and only remove local tracking. +PRs that are merged, merging, or queued for merge cannot be removed from a stack on GitHub and are left part of the stack. When every pull request is removed, the stack is dissolved and any local tracking is removed; when some pull requests remain stacked, the stack is kept and local tracking, if any, is unchanged. Use `--local` to skip the remote operation and only remove local tracking. This is useful when you need to restructure a stack — remove a branch, insert a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure — existing branches are adopted automatically. | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub) | **Examples:** ```sh -# Delete the stack on GitHub and remove local tracking +# Unstack the current stack on GitHub and remove local tracking gh stack unstack +# Unstack a specific stack by its number +gh stack unstack 7 + # Only remove local tracking gh stack unstack --local ``` @@ -278,6 +276,8 @@ In an interactive terminal, `submit` opens a full-screen editor on a single scre Press Ctrl+S to submit all included PRs at once. The editor supports both keyboard and mouse input. Pass `--auto` (or run in a non-interactive terminal, such as CI) to skip the editor and use auto-generated titles. +If the branches already have open PRs but no stack exists on GitHub, you will have the option to link the PRs into a stack with Ctrl+B. + In the editor, new PRs default to **ready for review**; flip any PR to **draft** with the ready ↔ draft toggle. With `--auto`, new PRs are created as **drafts** unless you pass `--open`. | Flag | Description | @@ -302,15 +302,28 @@ Fetch, rebase, push, and sync PR state in a single command. gh stack sync [flags] ``` -Performs a safe, non-interactive synchronization of the entire stack: +Performs a synchronization of the entire stack: 1. **Fetch** — fetches the latest changes from `origin`. -2. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged). -3. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. -4. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). -5. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. -6. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. This only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that). -7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. +2. **Reconcile the remote stack** — mirrors the GitHub stack locally. When PRs have been added to the stack on GitHub (the remote is ahead of your local stack), their branches are pulled down and appended to your local stack automatically. When the local and remote stacks have genuinely diverged (for example, you added a branch locally while different PRs were added to the stack on GitHub), you are prompted to resolve (see [Diverged stacks](#diverged-stacks) below). In a non-interactive terminal a divergence aborts the sync (nothing is pushed or updated). +3. **Fast-forward trunk** — fast-forwards the trunk branch to match the remote (skips if diverged). +4. **Cascade rebase** — rebases all stack branches onto their updated parents (only if trunk moved). If a conflict is detected, all branches are restored to their original state, and you are advised to run `gh stack rebase` to resolve conflicts interactively. +5. **Push** — pushes all branches (uses `--force-with-lease` if a rebase occurred). +6. **Sync PRs** — syncs PR state from GitHub and reports the status of each PR. +7. **Sync the stack** — links the stack's open PRs into a stack on GitHub, creating the remote stack object if it doesn't exist yet or updating it if it's partially formed. This only happens when two or more PRs exist; sync never opens PRs (use `gh stack submit` for that). +8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to prune automatically. + +A clean remote-ahead update (PRs added on top of your local stack) is pulled down automatically without prompting, so `sync` is safe to run in automation. Sync only prompts when the stacks have truly diverged. + +**Diverged stacks** + +When neither stack is a clean prefix of the other — for example, you added a branch locally while separate PRs were added to the same stack on GitHub — sync cannot merge the two automatically. In an interactive terminal it offers three choices: + +- **Use the remote stack as the source of truth** — replaces your local stack composition with the remote's, pulling any missing branches. If you were on a branch that the remote stack no longer contains, you're moved to the nearest surviving branch. Requires a clean working state with no uncommitted changes. +- **Delete the stack on GitHub** — deletes the stack object on GitHub and stops the sync. Your PRs and local branches are untouched (only the stack on GitHub is removed); recreate the stack with `gh stack submit` (run `gh stack modify` first if you want to change its structure). This is the way to make GitHub match your local stack, because `submit` — unlike `sync` — also creates PRs for any branches you haven't submitted yet. +- **Cancel** — aborts the sync without pushing branches or updating any PRs. + +In a non-interactive terminal, a divergence aborts the sync (exit success) without pushing branches or updating PRs; resolve it by unstacking and recreating the stack. | Flag | Description | |------|-------------| @@ -405,7 +418,7 @@ gh stack push --remote upstream Link PRs into a stack on GitHub without local tracking. ```sh -gh stack link [flags] [...] +gh stack link [flags] [...] ``` Creates or updates a stack on GitHub from branch names or PR numbers/URLs. This command does not create or modify any `gh-stack` local tracking state. It is designed for users who manage branches with other tools locally (e.g., jj, Sapling, git-town) and want to simply open a stack of PRs. @@ -414,9 +427,11 @@ Arguments are provided in stack order (bottom to top). Branch arguments are auto If the PRs are not yet in a stack, a new stack is created. If some of the PRs are already in a stack, the existing stack is updated to include the new PRs. Existing PRs are never removed from a stack — the update is additive only. +To grow an existing stack without re-listing its PRs, pass a stack number (the number shown in the GitHub stack UI) as the first argument. The remaining arguments are appended to the top of that stack. Arguments already in the stack are skipped, and arguments that belong to a different stack are rejected. Because stack and PR numbers never overlap, a numeric first argument is treated as a stack only when it matches an existing stack — otherwise it is treated as a PR or branch. + | Flag | Description | |------|-------------| -| `--base ` | Base branch for the bottom of the stack (default: `main`) | +| `--base ` | Base branch for the bottom of the stack (default: `main`); ignored when adding to an existing stack | | `--open` | Mark new and existing PRs as ready for review | | `--remote ` | Remote to push to (defaults to auto-detected remote) | @@ -435,6 +450,10 @@ gh stack link https://github.com/owner/repo/pull/10 https://github.com/owner/rep # Add branches to an existing stack of PRs gh stack link 42 43 feature-auth feature-ui +# Append to the top of an existing stack by its stack number (no need to +# re-list the PRs already in stack 7) +gh stack link 7 48 feature-ui + # Use a different base branch and mark PRs as ready for review gh stack link --base develop --open feat-a feat-b feat-c ``` diff --git a/internal/branch/name.go b/internal/branch/name.go index c1df8001..48a0b47a 100644 --- a/internal/branch/name.go +++ b/internal/branch/name.go @@ -1,9 +1,7 @@ package branch import ( - "fmt" "regexp" - "strconv" "strings" "time" "unicode" @@ -12,14 +10,14 @@ import ( ) var ( - nonAlphanumRe = regexp.MustCompile(`[^a-z0-9-]+`) - multiHyphenRe = regexp.MustCompile(`-{2,}`) - numberedBranchRe = regexp.MustCompile(`/(\d+)$`) + nonAllowedRe = regexp.MustCompile(`[^a-z0-9-]+`) + multiSepRe = regexp.MustCompile(`[-_]{2,}`) ) // Slugify converts a message into a URL/branch-safe slug. -// Lowercases, replaces special chars with hyphens, strips consecutive hyphens, -// and truncates to ~50 chars at a word boundary. +// Lowercases, replaces spaces and other disallowed characters with underscores +// (any hyphens already present in the message are preserved), collapses runs of +// adjacent separators, and truncates to ~30 chars at a word boundary. func Slugify(message string) string { // Normalize unicode and lowercase s := strings.ToLower(norm.NFKD.String(message)) @@ -33,100 +31,44 @@ func Slugify(message string) string { } s = b.String() - // Replace non-alphanumeric chars with hyphens - s = nonAlphanumRe.ReplaceAllString(s, "-") - - // Collapse consecutive hyphens - s = multiHyphenRe.ReplaceAllString(s, "-") + // Replace runs of disallowed chars (spaces, punctuation, …) with a single + // underscore. Hyphens present in the message are allowed and preserved. + s = nonAllowedRe.ReplaceAllString(s, "_") + + // Collapse any run of adjacent separators into a single character. A run + // that contains a hyphen the user actually typed collapses to a hyphen so + // literal hyphens survive (e.g. "fix--retry" → "fix-retry"); runs of purely + // generated underscores collapse to a single underscore. + s = multiSepRe.ReplaceAllStringFunc(s, func(run string) string { + if strings.ContainsRune(run, '-') { + return "-" + } + return "_" + }) - // Trim leading/trailing hyphens - s = strings.Trim(s, "-") + // Trim leading/trailing separators + s = strings.Trim(s, "-_") - // Truncate to ~50 chars at word boundary - if len(s) > 50 { - s = s[:50] - if idx := strings.LastIndex(s, "-"); idx > 0 { + // Truncate to ~30 chars at a word boundary (underscore) + if len(s) > 30 { + s = s[:30] + if idx := strings.LastIndex(s, "_"); idx > 0 { s = s[:idx] } + s = strings.Trim(s, "-_") } return s } -// DateSlug returns a branch name in the format YYYY-MM-DD-slugified-message. +// DateSlug returns a branch name in the format MM-DD-slugified-message. +// It is used to auto-generate a branch name from a commit message when no +// explicit branch name is provided. func DateSlug(message string) string { - date := time.Now().Format("2006-01-02") + date := time.Now().Format("01-02") slug := Slugify(message) if slug == "" { return date } return date + "-" + slug } - -// FollowsNumbering returns true if branchName matches the pattern {prefix}/\d+. -func FollowsNumbering(prefix, branchName string) bool { - if !strings.HasPrefix(branchName, prefix+"/") { - return false - } - suffix := branchName[len(prefix)+1:] - _, err := strconv.Atoi(suffix) - return err == nil -} - -// NextNumberedName scans existingBranches for the highest number matching -// {prefix}/NN and returns {prefix}/{next} with zero-padded two digits. -func NextNumberedName(prefix string, existingBranches []string) string { - maxNum := 0 - for _, b := range existingBranches { - if m := numberedBranchRe.FindStringSubmatch(b); m != nil { - if strings.HasPrefix(b, prefix+"/") { - n, _ := strconv.Atoi(m[1]) - if n > maxNum { - maxNum = n - } - } - } - } - return fmt.Sprintf("%s/%02d", prefix, maxNum+1) -} - -// ResolveBranchName implements the full decision tree for branch name generation. -// -// Parameters: -// - prefix: configured stack prefix (may be empty) -// - message: commit message (from -m flag; may be empty if not using auto-naming) -// - explicitName: branch name provided as argument (may be empty) -// - existingBranches: current branch names in the stack -// - numbered: true if the stack uses auto-incrementing numbered branches -// -// Returns the resolved branch name and an informational message (may be empty). -func ResolveBranchName(prefix, message, explicitName string, existingBranches []string, numbered bool) (name string, info string) { - if explicitName != "" { - // Explicit name provided - if prefix != "" { - name = prefix + "/" + explicitName - info = fmt.Sprintf("Branch name prefixed: %s", name) - } else { - name = explicitName - } - return - } - - // Auto-generate from message - if message == "" { - return "", "" - } - - if prefix != "" { - if numbered { - name = NextNumberedName(prefix, existingBranches) - } else { - name = prefix + "/" + DateSlug(message) - } - } else { - // No prefix — always use date+slug - name = DateSlug(message) - } - - return -} diff --git a/internal/branch/name_test.go b/internal/branch/name_test.go index 04df0ebf..53605fb5 100644 --- a/internal/branch/name_test.go +++ b/internal/branch/name_test.go @@ -16,9 +16,13 @@ func TestSlugify(t *testing.T) { input string expected string }{ - {"spaces to hyphens", "Hello World", "hello-world"}, - {"diacritics stripped", "café résumé", "cafe-resume"}, - {"special chars removed", "feat: add login!", "feat-add-login"}, + {"spaces to underscores", "Hello World", "hello_world"}, + {"diacritics stripped", "café résumé", "cafe_resume"}, + {"special chars become underscores", "feat: add login!", "feat_add_login"}, + {"real hyphens preserved", "Add user-authentication", "add_user-authentication"}, + {"adjacent hyphens preserved as a hyphen", "fix--retry", "fix-retry"}, + {"hyphen next to a space preserved as a hyphen", "fix- retry", "fix-retry"}, + {"spaced dash preserved as a hyphen", "fix - retry", "fix-retry"}, {"empty string", "", ""}, } @@ -31,102 +35,24 @@ func TestSlugify(t *testing.T) { t.Run("long string truncated at word boundary", func(t *testing.T) { long := "this is a very long commit message that should definitely be truncated at a word boundary" result := Slugify(long) - assert.LessOrEqual(t, len(result), 50) - assert.False(t, strings.HasSuffix(result, "-"), "should not end with hyphen") + assert.LessOrEqual(t, len(result), 30) + assert.False(t, strings.HasSuffix(result, "_"), "should not end with a separator") + assert.False(t, strings.HasSuffix(result, "-"), "should not end with a separator") assert.NotEmpty(t, result) }) } -// --- FollowsNumbering: pattern detection --- +// --- DateSlug: date-prefixed auto-naming --- -func TestFollowsNumbering(t *testing.T) { - tests := []struct { - name string - prefix string - branch string - expected bool - }{ - {"matching pattern", "stack", "stack/1", true}, - {"multi-digit", "stack", "stack/42", true}, - {"non-numeric suffix", "stack", "stack/abc", false}, - {"wrong prefix", "other", "stack/1", false}, - {"empty suffix", "stack", "stack/", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, FollowsNumbering(tt.prefix, tt.branch)) - }) - } -} - -// --- NextNumberedName: auto-increment --- - -func TestNextNumberedName(t *testing.T) { - t.Run("empty list starts at 01", func(t *testing.T) { - assert.Equal(t, "prefix/01", NextNumberedName("prefix", nil)) - }) - - t.Run("increments from highest", func(t *testing.T) { - branches := []string{"prefix/01", "prefix/02"} - assert.Equal(t, "prefix/03", NextNumberedName("prefix", branches)) - }) - - t.Run("handles gaps by using max", func(t *testing.T) { - branches := []string{"prefix/01", "prefix/05"} - assert.Equal(t, "prefix/06", NextNumberedName("prefix", branches)) - }) - - t.Run("ignores branches with different prefix", func(t *testing.T) { - branches := []string{"other/10", "prefix/02"} - assert.Equal(t, "prefix/03", NextNumberedName("prefix", branches)) - }) -} - -// --- ResolveBranchName: the full decision tree --- - -func TestResolveBranchName(t *testing.T) { - t.Run("explicit name with prefix uses slash separator", func(t *testing.T) { - name, info := ResolveBranchName("mystack", "", "feature", nil, false) - assert.Equal(t, "mystack/feature", name) - assert.Contains(t, info, "prefixed") - }) - - t.Run("explicit name without prefix uses name as-is", func(t *testing.T) { - name, info := ResolveBranchName("", "", "feature", nil, false) - assert.Equal(t, "feature", name) - assert.Empty(t, info) - }) - - t.Run("message with prefix and numbered uses numbered format", func(t *testing.T) { - name, _ := ResolveBranchName("stack", "add login", "", nil, true) - assert.Equal(t, "stack/01", name) - }) - - t.Run("message with prefix and numbered continues sequence", func(t *testing.T) { - existing := []string{"stack/01", "stack/02"} - name, _ := ResolveBranchName("stack", "add login", "", existing, true) - assert.Equal(t, "stack/03", name) - }) - - t.Run("message with prefix not numbered uses date-slug", func(t *testing.T) { - existing := []string{"stack/some-feature"} - name, _ := ResolveBranchName("stack", "add login", "", existing, false) - today := time.Now().Format("2006-01-02") - assert.True(t, strings.HasPrefix(name, "stack/"+today), "expected date prefix, got: %s", name) - assert.Contains(t, name, "add-login") - }) +func TestDateSlug(t *testing.T) { + today := time.Now().Format("01-02") - t.Run("message without prefix uses date-slug", func(t *testing.T) { - name, _ := ResolveBranchName("", "add login", "", nil, false) - today := time.Now().Format("2006-01-02") - assert.True(t, strings.HasPrefix(name, today)) - assert.Contains(t, name, "add-login") + t.Run("prefixes the date and slugifies the message", func(t *testing.T) { + name := DateSlug("Add login") + assert.Equal(t, today+"-add_login", name) }) - t.Run("no message no name returns empty", func(t *testing.T) { - name, info := ResolveBranchName("stack", "", "", nil, false) - assert.Empty(t, name) - assert.Empty(t, info) + t.Run("empty message returns just the date", func(t *testing.T) { + assert.Equal(t, today, DateSlug("")) }) } diff --git a/internal/config/auth.go b/internal/config/auth.go deleted file mode 100644 index 8fcc38b0..00000000 --- a/internal/config/auth.go +++ /dev/null @@ -1,65 +0,0 @@ -package config - -import ( - "strings" - - "github.com/cli/go-gh/v2/pkg/auth" -) - -// tokenForHost returns the auth token for the given host, using the -// test override if set or falling back to the real auth.TokenForHost. -func (cfg *Config) tokenForHost(host string) (string, string) { - if cfg.TokenForHostFn != nil { - return cfg.TokenForHostFn(host) - } - return auth.TokenForHost(host) -} - -// IsPersonalAccessToken reports whether the active token for the current -// repository's host is a personal access token (classic or fine-grained) -// rather than an OAuth token from `gh auth login`. -// -// Token prefix conventions: -// -// gho_ → OAuth token (supported) -// ghs_ → GitHub App installation token (supported) -// ghp_ → Classic personal access token (NOT supported) -// github_pat_ → Fine-grained personal access token (NOT supported) -func (cfg *Config) IsPersonalAccessToken() bool { - host := cfg.RepoHost() - if host == "" { - return false - } - return cfg.isPersonalAccessTokenForHost(host) -} - -// isPersonalAccessTokenForHost checks the token prefix for the given host. -func (cfg *Config) isPersonalAccessTokenForHost(host string) bool { - token, _ := cfg.tokenForHost(host) - if token == "" { - return false - } - return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "github_pat_") -} - -// RepoHost returns the GitHub host for the current repository, or an empty -// string if it cannot be determined (e.g. not inside a git repo). -func (cfg *Config) RepoHost() string { - repo, err := cfg.Repo() - if err != nil { - return "" - } - return repo.Host -} - -// WarnIfPAT checks whether the active token is a personal access token and, -// if so, prints a warning explaining that PATs are not supported by gh stack. -// Returns true when a PAT is detected. -func (cfg *Config) WarnIfPAT() bool { - if !cfg.IsPersonalAccessToken() { - return false - } - cfg.Warningf("Personal access tokens are not supported by gh stack during private preview") - cfg.Printf(" Run %s to authenticate with OAuth instead.", cfg.ColorCyan("gh auth login")) - return true -} diff --git a/internal/config/auth_test.go b/internal/config/auth_test.go deleted file mode 100644 index 797e2f5c..00000000 --- a/internal/config/auth_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package config - -import ( - "io" - "testing" - - "github.com/cli/go-gh/v2/pkg/repository" - "github.com/stretchr/testify/assert" -) - -// testRepo is a fake repository used in tests to avoid depending on the -// real git repo context (which may not exist in CI). -var testRepo = &repository.Repository{Host: "github.com", Owner: "o", Name: "r"} - -func TestIsPersonalAccessToken(t *testing.T) { - tests := []struct { - name string - token string - want bool - }{ - {"oauth token", "gho_abc123", false}, - {"app installation token", "ghs_abc123", false}, - {"classic PAT", "ghp_abc123", true}, - {"fine-grained PAT", "github_pat_abc123", true}, - {"empty token", "", false}, - {"unknown prefix", "some_other_token", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - TokenForHostFn: func(string) (string, string) { return tt.token, "test" }, - } - got := cfg.isPersonalAccessTokenForHost("github.com") - assert.Equal(t, tt.want, got) - }) - } -} - -func TestWarnIfPAT_DetectsPAT(t *testing.T) { - cfg, _, errR := NewTestConfig() - cfg.RepoOverride = testRepo - cfg.TokenForHostFn = func(string) (string, string) { return "ghp_classic_pat_token", "test" } - - result := cfg.WarnIfPAT() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.True(t, result) - assert.Contains(t, output, "Personal access tokens are not supported by gh stack") - assert.Contains(t, output, "gh auth login") -} - -func TestWarnIfPAT_IgnoresOAuth(t *testing.T) { - cfg, _, errR := NewTestConfig() - cfg.RepoOverride = testRepo - cfg.TokenForHostFn = func(string) (string, string) { return "gho_oauth_token", "test" } - - result := cfg.WarnIfPAT() - - cfg.Err.Close() - errOut, _ := io.ReadAll(errR) - output := string(errOut) - - assert.False(t, result) - assert.Empty(t, output) -} diff --git a/internal/config/config.go b/internal/config/config.go index 02dbff3d..bb411243 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,12 +45,7 @@ type Config struct { // InputFn, when non-nil, is called instead of prompting via the // terminal. Used in tests to simulate text input prompts. - InputFn func(prompt, defaultValue string) (string, error) - - // TokenForHostFn, when non-nil, is called instead of auth.TokenForHost - // to retrieve the auth token for a given GitHub host. Used in tests to - // simulate different token types (OAuth vs PAT). - TokenForHostFn func(host string) (string, string) + InputFn func(prompt string) (string, error) // RepoOverride, when non-nil, is returned by Repo() instead of // calling repository.Current(). Used in tests to avoid depending on diff --git a/internal/git/git.go b/internal/git/git.go index a8485592..678d13f8 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -399,10 +399,23 @@ func CherryPick(commits []string) error { return ops.CherryPick(commits) } -// CherryPickAbort clears any in-progress cherry-pick state. -// Errors are silently ignored (no-op if no cherry-pick is in progress). -func CherryPickAbort() { - _ = ops.CherryPickAbort() +// CherryPickQuit clears any in-progress cherry-pick sequencer state without +// touching the working tree or index. Errors are silently ignored (no-op if no +// cherry-pick is in progress). +func CherryPickQuit() { + _ = ops.CherryPickQuit() +} + +// CherryPickAbort cancels an in-progress cherry-pick, restoring the working +// tree and index to the state before the cherry-pick began. Callers should +// gate this with IsCherryPickInProgress, as it errors when none is in progress. +func CherryPickAbort() error { + return ops.CherryPickAbort() +} + +// IsCherryPickInProgress reports whether a cherry-pick is currently in progress. +func IsCherryPickInProgress() bool { + return ops.IsCherryPickInProgress() } // CherryPickContinue continues an in-progress cherry-pick after conflicts are resolved. diff --git a/internal/git/gitops.go b/internal/git/gitops.go index bd4fe3ea..523c8e68 100644 --- a/internal/git/gitops.go +++ b/internal/git/gitops.go @@ -69,8 +69,10 @@ type Ops interface { ValidateRefName(name string) error RenameBranch(oldName, newName string) error CherryPick(commits []string) error + CherryPickQuit() error CherryPickAbort() error CherryPickContinue() error + IsCherryPickInProgress() bool HasUncommittedChanges() (bool, error) LogMerges(base, head string) ([]CommitInfo, error) } @@ -187,13 +189,14 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) if atomic { args = append(args, "--atomic") } - if force { - // Explicit refspecs: :refs/heads/. - for _, b := range branches { - args = append(args, fmt.Sprintf("%s:refs/heads/%s", b, b)) - } - } else { - args = append(args, branches...) + // Fully-qualified refspecs: refs/heads/:refs/heads/. + // Qualifying the source (not a bare branch name) ensures a branch name is + // never reinterpreted as refspec syntax — e.g. a leading "+" is part of the + // ref, not a force modifier. This form is identical whether or not the push + // is forced; force is supplied out-of-band by the --force-with-lease flags + // built above. + for _, b := range branches { + args = append(args, fmt.Sprintf("refs/heads/%s:refs/heads/%s", b, b)) } return runSilent(args...) } @@ -544,7 +547,9 @@ func (d *defaultOps) DeleteBranch(name string, force bool) error { } func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { - return runSilent("push", remote, "--delete", branch) + // Fully-qualify the ref so a branch name is never reinterpreted as + // refspec syntax. + return runSilent("push", remote, "--delete", "refs/heads/"+branch) } func (d *defaultOps) DeleteTrackingRef(remote, branch string) error { @@ -609,16 +614,40 @@ func (d *defaultOps) CherryPick(commits []string) error { return runSilent(args...) } -func (d *defaultOps) CherryPickAbort() error { +// 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") } +// CherryPickAbort cancels an in-progress cherry-pick and restores the working +// tree and index to the state before the cherry-pick began +// (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") +} + func (d *defaultOps) CherryPickContinue() error { cmd := exec.Command("git", "cherry-pick", "--continue") cmd.Env = append(os.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. +func (d *defaultOps) IsCherryPickInProgress() bool { + gitDir, err := GitDir() + if err != nil { + return false + } + if _, err := os.Stat(filepath.Join(gitDir, "CHERRY_PICK_HEAD")); err == nil { + return true + } + return false +} + func (d *defaultOps) HasUncommittedChanges() (bool, error) { out, err := run("status", "--porcelain") if err != nil { diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go index 24ca6807..3d5a5f3d 100644 --- a/internal/git/gitops_test.go +++ b/internal/git/gitops_test.go @@ -361,6 +361,100 @@ func TestIntegration_Push_MixedStack(t *testing.T) { assert.Equal(t, localB2, remoteBranchSHA(t, bareDir, "b2")) } +// A stack branch whose name begins with "+" must push its own ref, not a +// similarly named sibling. A leading "+" in a git refspec means "force update", +// so passing a bare "+feature" (or "+feature:...") lets git treat it as a +// refspec modifier for "feature". Fully-qualified refspecs keep the "+" part of +// the branch name. Force path (used by push/submit). +func TestIntegration_Push_PlusPrefixedBranch_Force(t *testing.T) { + bareDir, cloneDir := setupBareAndClone(t) + restore := withGitDir(t, cloneDir) + defer restore() + + d := &defaultOps{} + + // Create and push a normal "feature" branch (content A). + gitExec(t, cloneDir, "checkout", "-b", "feature") + writeFile(t, cloneDir, "feature.txt", "A") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature A") + gitExec(t, cloneDir, "push", "origin", "feature") + remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature") + + // Create a local "+feature" branch off main with different content (B). + gitExec(t, cloneDir, "checkout", "main") + gitExec(t, cloneDir, "checkout", "-b", "+feature") + writeFile(t, cloneDir, "plus.txt", "B") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "plus B") + localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature") + + // Advance local "feature" (content C) but do NOT push it. If the push + // followed refspec syntax, "+feature" would push this ref instead. + gitExec(t, cloneDir, "checkout", "feature") + writeFile(t, cloneDir, "feature.txt", "C") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature C") + localFeature := gitExec(t, cloneDir, "rev-parse", "refs/heads/feature") + require.NotEqual(t, localPlus, localFeature, "test setup: +feature and feature must differ") + + // Push the "+feature" stack branch via the force path. + gitExec(t, cloneDir, "checkout", "+feature") + require.NoError(t, d.FetchBranches("origin", []string{"+feature"})) + require.NoError(t, d.Push("origin", []string{"+feature"}, true, false)) + + // Remote "+feature" must point at local "+feature" (B), and remote + // "feature" must be untouched (still A). + assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"), + "remote +feature should hold the +feature commit, not feature's") + assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"), + "remote feature must not be force-updated") +} + +// Same as above for the non-force, atomic path (used by link and by sync when +// no rebase happened). A bare "+feature" operand would force-update remote +// "feature"; a fully-qualified refspec creates remote "+feature" instead. +func TestIntegration_Push_PlusPrefixedBranch_NonForce(t *testing.T) { + bareDir, cloneDir := setupBareAndClone(t) + restore := withGitDir(t, cloneDir) + defer restore() + + d := &defaultOps{} + + // Create and push a normal "feature" branch (content A). + gitExec(t, cloneDir, "checkout", "-b", "feature") + writeFile(t, cloneDir, "feature.txt", "A") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature A") + gitExec(t, cloneDir, "push", "origin", "feature") + remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature") + + // Create a local "+feature" branch off main with different content (B). + gitExec(t, cloneDir, "checkout", "main") + gitExec(t, cloneDir, "checkout", "-b", "+feature") + writeFile(t, cloneDir, "plus.txt", "B") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "plus B") + localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature") + + // Advance local "feature" (content C) but do NOT push it. + gitExec(t, cloneDir, "checkout", "feature") + writeFile(t, cloneDir, "feature.txt", "C") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature C") + + // Push "+feature" via the non-force, atomic path. + gitExec(t, cloneDir, "checkout", "+feature") + require.NoError(t, d.Push("origin", []string{"+feature"}, false, true)) + + // Remote "+feature" must be created from local "+feature" (B), and remote + // "feature" must be untouched (still A). + assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"), + "remote +feature should be created from the +feature commit") + assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"), + "remote feature must not be force-updated") +} + func TestSplitCommitMessage(t *testing.T) { tests := []struct { name string @@ -487,3 +581,89 @@ func TestIntegration_SaveAndGetRemote(t *testing.T) { _, err = GetSavedRemote() require.Error(t, err) } + +// --------------------------------------------------------------------------- +// Integration tests for cherry-pick in-progress detection and abort +// --------------------------------------------------------------------------- + +// A conflicting cherry-pick must be detected as in-progress, and CherryPickAbort +// must fully restore the working tree/index so branch checkouts succeed again. +// This underpins modify's --abort recovery for fold-down (cherry-pick) conflicts. +func TestIntegration_CherryPickInProgressAndAbort(t *testing.T) { + _, cloneDir := setupBareAndClone(t) + restore := withGitDir(t, cloneDir) + defer restore() + + // Ensure runSilent-based git commands have a committer identity. + gitExec(t, cloneDir, "config", "user.name", "Test") + gitExec(t, cloneDir, "config", "user.email", "test@test.com") + + // feature edits conflict.txt one way; main edits it another way. + gitExec(t, cloneDir, "checkout", "-b", "feature") + writeFile(t, cloneDir, "conflict.txt", "feature change\n") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature edit") + featureSHA := gitExec(t, cloneDir, "rev-parse", "feature") + + gitExec(t, cloneDir, "checkout", "main") + writeFile(t, cloneDir, "conflict.txt", "main change\n") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "main edit") + + // No cherry-pick in progress before we start. + assert.False(t, IsCherryPickInProgress(), "no cherry-pick should be in progress initially") + + // Cherry-picking feature onto main conflicts. + err := CherryPick([]string{featureSHA}) + require.Error(t, err, "cherry-pick should conflict") + assert.True(t, IsCherryPickInProgress(), "cherry-pick should be in progress after a conflict") + + // While mid-conflict, a plain checkout must fail (unmerged index). + _, coErr := gitExecMayFail(t, cloneDir, "checkout", "feature") + require.Error(t, coErr, "checkout should fail while cherry-pick index is unmerged") + + // Aborting must fully restore: no longer in progress, clean tree, checkout works. + require.NoError(t, CherryPickAbort()) + assert.False(t, IsCherryPickInProgress(), "cherry-pick should not be in progress after abort") + + status, err := gitExecMayFail(t, cloneDir, "status", "--porcelain") + require.NoError(t, err) + assert.Empty(t, status, "working tree should be clean after abort") + + _, coErr = gitExecMayFail(t, cloneDir, "checkout", "feature") + require.NoError(t, coErr, "checkout should succeed after abort restores a clean index") +} + +// CherryPickQuit clears the sequencer state but intentionally leaves the index +// as-is, so a plain checkout still fails. This documents why Unwind uses the +// full --abort rather than --quit. +func TestIntegration_CherryPickQuitLeavesIndexUnmerged(t *testing.T) { + _, cloneDir := setupBareAndClone(t) + restore := withGitDir(t, cloneDir) + defer restore() + + gitExec(t, cloneDir, "config", "user.name", "Test") + gitExec(t, cloneDir, "config", "user.email", "test@test.com") + + gitExec(t, cloneDir, "checkout", "-b", "feature") + writeFile(t, cloneDir, "conflict.txt", "feature change\n") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "feature edit") + featureSHA := gitExec(t, cloneDir, "rev-parse", "feature") + + gitExec(t, cloneDir, "checkout", "main") + writeFile(t, cloneDir, "conflict.txt", "main change\n") + gitExec(t, cloneDir, "add", ".") + gitExec(t, cloneDir, "commit", "-m", "main edit") + + require.Error(t, CherryPick([]string{featureSHA})) + require.True(t, IsCherryPickInProgress()) + + // --quit clears sequencer state (no longer "in progress") ... + CherryPickQuit() + assert.False(t, IsCherryPickInProgress(), "quit should clear cherry-pick sequencer state") + + // ... but leaves the unmerged index behind, so checkout still fails. + _, coErr := gitExecMayFail(t, cloneDir, "checkout", "feature") + require.Error(t, coErr, "checkout should still fail after --quit because the index is unmerged") +} diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go index 29a62132..9c2ce434 100644 --- a/internal/git/mock_ops.go +++ b/internal/git/mock_ops.go @@ -6,56 +6,60 @@ import "fmt" // Each field is an optional function that, when set, handles the corresponding // Ops method call. When nil, a reasonable default is returned. type MockOps struct { - GitDirFn func() (string, error) - RootDirFn func() (string, error) - CurrentBranchFn func() (string, error) - BranchExistsFn func(string) bool - CheckoutBranchFn func(string) error - FetchFn func(string) error - FetchBranchesFn func(string, []string) error - DefaultBranchFn func() (string, error) - CreateBranchFn func(string, string) error - PushFn func(string, []string, bool, bool) error - ResolveRemoteFn func(string) (string, error) - RebaseFn func(string, RebaseOpts) error - EnableRerereFn func() error - IsRerereEnabledFn func() (bool, error) - IsRerereDeclinedFn func() (bool, error) - SaveRerereDeclinedFn func() error - GetSavedRemoteFn func() (string, error) - SaveRemoteFn func(string) error - ClearRemoteFn func() error - RebaseOntoFn func(string, string, string, RebaseOpts) error - RebaseContinueFn func(RebaseOpts) error - RebaseAbortFn func() error - IsRebaseInProgressFn func() bool - ConflictedFilesFn func() ([]string, error) - FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) - IsAncestorFn func(string, string) (bool, error) - RevParseFn func(string) (string, error) - RevParseMultiFn func([]string) ([]string, error) - MergeBaseFn func(string, string) (string, error) - LogFn func(string, int) ([]CommitInfo, error) - LogRangeFn func(string, string) ([]CommitInfo, error) - DiffStatRangeFn func(string, string) (int, int, error) - DiffStatFilesFn func(string, string) ([]FileDiffStat, error) - DeleteBranchFn func(string, bool) error - DeleteRemoteBranchFn func(string, string) error - DeleteTrackingRefFn func(string, string) error - ResetHardFn func(string) error - SetUpstreamTrackingFn func(string, string) error - MergeFFFn func(string) error - UpdateBranchRefFn func(string, string) error - StageAllFn func() error - StageTrackedFn func() error - HasStagedChangesFn func() bool - CommitFn func(string) (string, error) - CommitInteractiveFn func() (string, error) - ValidateRefNameFn func(string) error - RenameBranchFn func(string, string) error - CherryPickFn func([]string) error - HasUncommittedChangesFn func() (bool, error) - LogMergesFn func(string, string) ([]CommitInfo, error) + GitDirFn func() (string, error) + RootDirFn func() (string, error) + CurrentBranchFn func() (string, error) + BranchExistsFn func(string) bool + CheckoutBranchFn func(string) error + FetchFn func(string) error + FetchBranchesFn func(string, []string) error + DefaultBranchFn func() (string, error) + CreateBranchFn func(string, string) error + PushFn func(string, []string, bool, bool) error + ResolveRemoteFn func(string) (string, error) + RebaseFn func(string, RebaseOpts) error + EnableRerereFn func() error + IsRerereEnabledFn func() (bool, error) + IsRerereDeclinedFn func() (bool, error) + SaveRerereDeclinedFn func() error + GetSavedRemoteFn func() (string, error) + SaveRemoteFn func(string) error + ClearRemoteFn func() error + RebaseOntoFn func(string, string, string, RebaseOpts) error + RebaseContinueFn func(RebaseOpts) error + RebaseAbortFn func() error + IsRebaseInProgressFn func() bool + ConflictedFilesFn func() ([]string, error) + FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error) + IsAncestorFn func(string, string) (bool, error) + RevParseFn func(string) (string, error) + RevParseMultiFn func([]string) ([]string, error) + MergeBaseFn func(string, string) (string, error) + LogFn func(string, int) ([]CommitInfo, error) + LogRangeFn func(string, string) ([]CommitInfo, error) + DiffStatRangeFn func(string, string) (int, int, error) + DiffStatFilesFn func(string, string) ([]FileDiffStat, error) + DeleteBranchFn func(string, bool) error + DeleteRemoteBranchFn func(string, string) error + DeleteTrackingRefFn func(string, string) error + ResetHardFn func(string) error + SetUpstreamTrackingFn func(string, string) error + MergeFFFn func(string) error + UpdateBranchRefFn func(string, string) error + StageAllFn func() error + StageTrackedFn func() error + HasStagedChangesFn func() bool + CommitFn func(string) (string, error) + CommitInteractiveFn func() (string, error) + ValidateRefNameFn func(string) error + RenameBranchFn func(string, string) error + CherryPickFn func([]string) error + CherryPickQuitFn func() error + CherryPickAbortFn func() error + CherryPickContinueFn func() error + IsCherryPickInProgressFn func() bool + HasUncommittedChangesFn func() (bool, error) + LogMergesFn func(string, string) ([]CommitInfo, error) } var _ Ops = (*MockOps)(nil) @@ -405,11 +409,31 @@ func (m *MockOps) CherryPick(commits []string) error { return nil } +func (m *MockOps) CherryPickQuit() error { + if m.CherryPickQuitFn != nil { + return m.CherryPickQuitFn() + } + return nil +} + func (m *MockOps) CherryPickAbort() error { + if m.CherryPickAbortFn != nil { + return m.CherryPickAbortFn() + } return nil } +func (m *MockOps) IsCherryPickInProgress() bool { + if m.IsCherryPickInProgressFn != nil { + return m.IsCherryPickInProgressFn() + } + return false +} + func (m *MockOps) CherryPickContinue() error { + if m.CherryPickContinueFn != nil { + return m.CherryPickContinueFn() + } return nil } diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go index 7ff9bdbf..7e613814 100644 --- a/internal/github/client_interface.go +++ b/internal/github/client_interface.go @@ -12,9 +12,11 @@ type ClientOps interface { MarkPRReadyForReview(prID string) error DisableAutoMerge(prID string) error ListStacks() ([]RemoteStack, error) - CreateStack(prNumbers []int) (int, error) - UpdateStack(stackID string, prNumbers []int) error - DeleteStack(stackID string) error + FindStackForPR(prNumber int) (*RemoteStack, error) + GetStack(stackNumber int) (*RemoteStack, error) + CreateStack(prNumbers []int) (*RemoteStack, error) + AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) + Unstack(stackNumber int) (*RemoteStack, bool, error) } // Compile-time check that Client satisfies ClientOps. diff --git a/internal/github/github.go b/internal/github/github.go index 78d33706..2bd577ca 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math" + "net/http" "github.com/cli/go-gh/v2/pkg/api" graphql "github.com/cli/shurcooL-graphql" @@ -399,16 +400,96 @@ func toGraphQLInt(n int) (graphql.Int, error) { return graphql.Int(n), nil } +// RemoteStackBase describes the base ref (and optionally SHA) of a stack. +type RemoteStackBase struct { + Ref string `json:"ref"` + Sha string `json:"sha,omitempty"` +} + +// RemoteStackPRHead describes the head ref of a pull request in a stack. +type RemoteStackPRHead struct { + Ref string `json:"ref"` + Sha string `json:"sha"` +} + +// RemoteStackPR is a pull request entry within a remote stack, as returned by +// the Stacks REST API list/detail endpoints. +type RemoteStackPR struct { + Number int `json:"number"` + State string `json:"state"` // open, closed + Draft bool `json:"draft"` + MergedAt *string `json:"merged_at"` + Head RemoteStackPRHead `json:"head"` +} + +// IsMerged reports whether the pull request has been merged. +func (p RemoteStackPR) IsMerged() bool { + return p.MergedAt != nil && *p.MergedAt != "" +} + +// RemoteStack represents a stack of pull requests as returned by the public +// Stacks REST API (GET/POST /repos/{owner}/{repo}/stacks...). ID is the +// internal identifier; Number is the human-facing stack number shown in the +// github.com UI and used to address the stack in API paths. +// +// The API returns pull_requests as an array of objects; UnmarshalJSON flattens +// them to the ordered PullRequests numbers (bottom to top) and preserves the +// full entries in PRDetails for callers that need head refs or PR state. type RemoteStack struct { - ID int `json:"id"` - PullRequests []int `json:"pull_requests"` + ID int `json:"id"` + Number int `json:"number"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Base RemoteStackBase `json:"base"` + Open bool `json:"open"` + CreatedAt string `json:"created_at"` + PullRequests []int `json:"-"` + PRDetails []RemoteStackPR `json:"-"` +} + +// UnmarshalJSON decodes the Stacks REST API representation, deriving the +// ordered PullRequests numbers from the pull_requests objects. +func (s *RemoteStack) UnmarshalJSON(data []byte) error { + type wire struct { + ID int `json:"id"` + Number int `json:"number"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Base RemoteStackBase `json:"base"` + Open bool `json:"open"` + CreatedAt string `json:"created_at"` + PullRequests []RemoteStackPR `json:"pull_requests"` + } + var w wire + if err := json.Unmarshal(data, &w); err != nil { + return err + } + s.ID = w.ID + s.Number = w.Number + s.NodeID = w.NodeID + s.URL = w.URL + s.Base = w.Base + s.Open = w.Open + s.CreatedAt = w.CreatedAt + s.PRDetails = w.PullRequests + s.PullRequests = make([]int, len(w.PullRequests)) + for i, p := range w.PullRequests { + s.PullRequests[i] = p.Number + } + return nil } -// ListStacks returns all stacks in the repository. -// Returns an empty slice if no stacks exist. -// A 404 response indicates stacked PRs are not enabled for this repository. +// PRNumbers returns the ordered pull request numbers in the stack, from bottom +// to top. +func (s *RemoteStack) PRNumbers() []int { + return s.PullRequests +} + +// ListStacks returns all stacks in the repository, ordered by stack number +// (descending). Returns an empty slice if no stacks exist. A 404 response +// indicates stacked PRs are not enabled for this repository. func (c *Client) ListStacks() ([]RemoteStack, error) { - path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo) + path := fmt.Sprintf("repos/%s/%s/stacks", c.owner, c.repo) var stacks []RemoteStack if err := c.rest.Get(path, &stacks); err != nil { return nil, err @@ -419,58 +500,92 @@ func (c *Client) ListStacks() ([]RemoteStack, error) { return stacks, nil } -// CreateStack creates a stack on GitHub from an ordered list of PR numbers. -// The PR numbers must be ordered from bottom to top of the stack and must -// form a valid base-to-head chain. Returns the server-assigned stack ID. -func (c *Client) CreateStack(prNumbers []int) (int, error) { - type createStackRequest struct { - PullRequestNumbers []int `json:"pull_request_numbers"` +// FindStackForPR returns the stack that contains the given pull request number, +// using the list endpoint's server-side pull_request filter. Returns nil +// (without error) when the PR is not part of any stack. +func (c *Client) FindStackForPR(prNumber int) (*RemoteStack, error) { + path := fmt.Sprintf("repos/%s/%s/stacks?pull_request=%d", c.owner, c.repo, prNumber) + var stacks []RemoteStack + if err := c.rest.Get(path, &stacks); err != nil { + return nil, err } - - body, err := json.Marshal(createStackRequest{PullRequestNumbers: prNumbers}) - if err != nil { - return 0, fmt.Errorf("marshaling request: %w", err) + if len(stacks) == 0 { + return nil, nil } + return &stacks[0], nil +} - path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks", c.owner, c.repo) +// GetStack fetches a single stack by its stack number. +func (c *Client) GetStack(stackNumber int) (*RemoteStack, error) { + path := fmt.Sprintf("repos/%s/%s/stacks/%d", c.owner, c.repo, stackNumber) + var rs RemoteStack + if err := c.rest.Get(path, &rs); err != nil { + return nil, err + } + return &rs, nil +} - var response struct { - ID int `json:"id"` +// CreateStack creates a stack on GitHub from an ordered list of PR numbers. +// The PR numbers must be ordered from bottom to top of the stack (at least two) +// and must form a valid base-to-head chain. Returns the created stack. +func (c *Client) CreateStack(prNumbers []int) (*RemoteStack, error) { + type createStackRequest struct { + PullRequests []int `json:"pull_requests"` } - if err := c.rest.Post(path, bytes.NewReader(body), &response); err != nil { - return 0, err + body, err := json.Marshal(createStackRequest{PullRequests: prNumbers}) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) } - return response.ID, nil + path := fmt.Sprintf("repos/%s/%s/stacks", c.owner, c.repo) + var rs RemoteStack + if err := c.rest.Post(path, bytes.NewReader(body), &rs); err != nil { + return nil, err + } + return &rs, nil } -// UpdateStack adds pull requests to an existing stack on GitHub. -// The stack is identified by stackID. The full list of PR numbers in the -// updated stack must be provided, including existing and new PRs, ordered -// from bottom to top. -func (c *Client) UpdateStack(stackID string, prNumbers []int) error { - type updateStackRequest struct { - PullRequestNumbers []int `json:"pull_request_numbers"` +// AddToStack appends pull requests to the top of an existing stack. Only the +// new PR numbers (the delta) should be provided, ordered from the current top +// of the stack upward. Returns the updated stack. +func (c *Client) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) { + type addToStackRequest struct { + PullRequests []int `json:"pull_requests"` } - body, err := json.Marshal(updateStackRequest{PullRequestNumbers: prNumbers}) + body, err := json.Marshal(addToStackRequest{PullRequests: prNumbers}) if err != nil { - return fmt.Errorf("marshaling request: %w", err) + return nil, fmt.Errorf("marshaling request: %w", err) } - path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID) + path := fmt.Sprintf("repos/%s/%s/stacks/%d/add", c.owner, c.repo, stackNumber) + var rs RemoteStack + if err := c.rest.Post(path, bytes.NewReader(body), &rs); err != nil { + return nil, err + } + return &rs, nil +} - var response struct { - ID int `json:"id"` +// Unstack removes unlocked pull requests from a stack. The server leaves PRs +// that cannot be unstacked (queued for merge or with auto-merge enabled) in +// place. When PRs remain, the updated stack is returned with dissolved=false; +// when none remain the stack is destroyed and dissolved=true (HTTP 204). +func (c *Client) Unstack(stackNumber int) (rs *RemoteStack, dissolved bool, err error) { + path := fmt.Sprintf("repos/%s/%s/stacks/%d/unstack", c.owner, c.repo, stackNumber) + resp, err := c.rest.Request(http.MethodPost, path, nil) + if err != nil { + return nil, false, err } + defer func() { _ = resp.Body.Close() }() - return c.rest.Put(path, bytes.NewReader(body), &response) -} + if resp.StatusCode == http.StatusNoContent { + return nil, true, nil + } -// DeleteStack deletes a stack on GitHub. -// The stack is identified by stackID. Returns nil on success (204). -func (c *Client) DeleteStack(stackID string) error { - path := fmt.Sprintf("repos/%s/%s/cli_internal/pulls/stacks/%s", c.owner, c.repo, stackID) - return c.rest.Delete(path, nil) + var remaining RemoteStack + if decErr := json.NewDecoder(resp.Body).Decode(&remaining); decErr != nil { + return nil, false, fmt.Errorf("decoding unstack response: %w", decErr) + } + return &remaining, false, nil } diff --git a/internal/github/github_test.go b/internal/github/github_test.go index a7e78229..1f18edf1 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -1,10 +1,12 @@ package github import ( + "encoding/json" "testing" graphql "github.com/cli/shurcooL-graphql" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPRURL(t *testing.T) { @@ -80,3 +82,69 @@ func TestToGraphQLInt(t *testing.T) { assert.Error(t, err) }) } + +// TestRemoteStack_UnmarshalJSON verifies the custom decoder that flattens the +// Stacks REST API's pull_requests object array into ordered PullRequests +// numbers while preserving the full entries in PRDetails. This is the only path +// that populates those fields from the wire, so a shape regression here would +// silently break every stack endpoint. +func TestRemoteStack_UnmarshalJSON(t *testing.T) { + payload := `{ + "id": 6154, + "number": 360, + "node_id": "S_kwABCD", + "url": "https://api.github.com/repos/o/r/stacks/360", + "base": {"ref": "main", "sha": "basesha"}, + "open": true, + "created_at": "2026-01-01T00:00:00Z", + "pull_requests": [ + {"number": 12, "state": "open", "draft": true, "merged_at": null, "head": {"ref": "feat-1", "sha": "sha1"}}, + {"number": 15, "state": "closed", "draft": false, "merged_at": "2026-01-02T00:00:00Z", "head": {"ref": "feat-2", "sha": "sha2"}} + ] + }` + + var s RemoteStack + require.NoError(t, json.Unmarshal([]byte(payload), &s)) + + // Top-level metadata. + assert.Equal(t, 6154, s.ID) + assert.Equal(t, 360, s.Number) + assert.Equal(t, "S_kwABCD", s.NodeID) + assert.Equal(t, "https://api.github.com/repos/o/r/stacks/360", s.URL) + assert.Equal(t, "main", s.Base.Ref) + assert.Equal(t, "basesha", s.Base.Sha) + assert.True(t, s.Open) + assert.Equal(t, "2026-01-01T00:00:00Z", s.CreatedAt) + + // Ordered PR numbers (bottom to top) derived from the object array. + assert.Equal(t, []int{12, 15}, s.PullRequests) + assert.Equal(t, []int{12, 15}, s.PRNumbers()) + + // Full PR entries preserved in PRDetails, including nullable merged_at. + require.Len(t, s.PRDetails, 2) + assert.Equal(t, 12, s.PRDetails[0].Number) + assert.Equal(t, "open", s.PRDetails[0].State) + assert.True(t, s.PRDetails[0].Draft) + assert.Nil(t, s.PRDetails[0].MergedAt) + assert.False(t, s.PRDetails[0].IsMerged()) + assert.Equal(t, "feat-1", s.PRDetails[0].Head.Ref) + assert.Equal(t, "sha1", s.PRDetails[0].Head.Sha) + + assert.Equal(t, 15, s.PRDetails[1].Number) + assert.Equal(t, "closed", s.PRDetails[1].State) + require.NotNil(t, s.PRDetails[1].MergedAt) + assert.Equal(t, "2026-01-02T00:00:00Z", *s.PRDetails[1].MergedAt) + assert.True(t, s.PRDetails[1].IsMerged()) + assert.Equal(t, "feat-2", s.PRDetails[1].Head.Ref) +} + +// TestRemoteStack_UnmarshalJSON_EmptyPRs ensures a stack with no pull_requests +// decodes to empty (non-nil) slices rather than panicking. +func TestRemoteStack_UnmarshalJSON_EmptyPRs(t *testing.T) { + var s RemoteStack + require.NoError(t, json.Unmarshal([]byte(`{"id": 1, "number": 2, "pull_requests": []}`), &s)) + assert.Equal(t, 1, s.ID) + assert.Equal(t, 2, s.Number) + assert.Empty(t, s.PullRequests) + assert.Empty(t, s.PRDetails) +} diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go index 5bff2b85..6c3a4678 100644 --- a/internal/github/mock_client.go +++ b/internal/github/mock_client.go @@ -12,9 +12,11 @@ type MockClient struct { MarkPRReadyForReviewFn func(string) error DisableAutoMergeFn func(string) error ListStacksFn func() ([]RemoteStack, error) - CreateStackFn func([]int) (int, error) - UpdateStackFn func(string, []int) error - DeleteStackFn func(string) error + FindStackForPRFn func(int) (*RemoteStack, error) + GetStackFn func(int) (*RemoteStack, error) + CreateStackFn func([]int) (*RemoteStack, error) + AddToStackFn func(int, []int) (*RemoteStack, error) + UnstackFn func(int) (*RemoteStack, bool, error) } // Compile-time check that MockClient satisfies ClientOps. @@ -76,23 +78,37 @@ func (m *MockClient) ListStacks() ([]RemoteStack, error) { return nil, nil } -func (m *MockClient) CreateStack(prNumbers []int) (int, error) { +func (m *MockClient) FindStackForPR(prNumber int) (*RemoteStack, error) { + if m.FindStackForPRFn != nil { + return m.FindStackForPRFn(prNumber) + } + return nil, nil +} + +func (m *MockClient) GetStack(stackNumber int) (*RemoteStack, error) { + if m.GetStackFn != nil { + return m.GetStackFn(stackNumber) + } + return &RemoteStack{}, nil +} + +func (m *MockClient) CreateStack(prNumbers []int) (*RemoteStack, error) { if m.CreateStackFn != nil { return m.CreateStackFn(prNumbers) } - return 0, nil + return &RemoteStack{}, nil } -func (m *MockClient) UpdateStack(stackID string, prNumbers []int) error { - if m.UpdateStackFn != nil { - return m.UpdateStackFn(stackID, prNumbers) +func (m *MockClient) AddToStack(stackNumber int, prNumbers []int) (*RemoteStack, error) { + if m.AddToStackFn != nil { + return m.AddToStackFn(stackNumber, prNumbers) } - return nil + return &RemoteStack{}, nil } -func (m *MockClient) DeleteStack(stackID string) error { - if m.DeleteStackFn != nil { - return m.DeleteStackFn(stackID) +func (m *MockClient) Unstack(stackNumber int) (*RemoteStack, bool, error) { + if m.UnstackFn != nil { + return m.UnstackFn(stackNumber) } - return nil + return nil, false, nil } diff --git a/internal/modify/apply.go b/internal/modify/apply.go index 8734a220..6aafc31d 100644 --- a/internal/modify/apply.go +++ b/internal/modify/apply.go @@ -397,7 +397,7 @@ func ApplyPlan( shas[len(commits)-1-i] = c.SHA } - git.CherryPickAbort() + git.CherryPickQuit() if err := git.CherryPick(shas); err != nil { conflict := &modifyview.ConflictInfo{Branch: foldBranch} @@ -754,32 +754,17 @@ func adjacentSnapshotBranch(snapshot Snapshot, target string, direction int) str // still exists in the stack. Prefers the branch above (higher index), then below. // resolvedName translates snapshot names through any renames from the same operation. func nearestSurvivingBranch(snapshot Snapshot, dropped string, s *stack.Stack, resolvedName func(string) string) string { - pos := -1 + order := make([]string, len(snapshot.Branches)) for i, bs := range snapshot.Branches { - if bs.Name == dropped { - pos = i - break - } + order[i] = bs.Name } - if pos < 0 { + raw := stack.NearestSurvivingBranch(order, dropped, func(name string) bool { + return s.IndexOf(resolvedName(name)) >= 0 + }) + if raw == "" { return "" } - - // Search above first (higher indices = away from trunk) - for i := pos + 1; i < len(snapshot.Branches); i++ { - name := resolvedName(snapshot.Branches[i].Name) - if s.IndexOf(name) >= 0 { - return name - } - } - // Then below (lower indices = toward trunk) - for i := pos - 1; i >= 0; i-- { - name := resolvedName(snapshot.Branches[i].Name) - if s.IndexOf(name) >= 0 { - return name - } - } - return "" + return resolvedName(raw) } // ContinueApply resumes a modify operation after the user resolves a rebase conflict. @@ -906,10 +891,26 @@ func ContinueApply( } } 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 { @@ -977,10 +978,16 @@ 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. func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int, sf *stack.StackFile, plan []Action) error { - // Abort any in-progress rebase + // 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() + } // Restore branch tips snapshotNames := make(map[string]bool, len(snapshot.Branches)) diff --git a/internal/modify/apply_test.go b/internal/modify/apply_test.go index 4f654c39..0ad6059c 100644 --- a/internal/modify/apply_test.go +++ b/internal/modify/apply_test.go @@ -51,7 +51,7 @@ func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { } return "sha-" + ref, nil }, - IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, + IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, CheckoutBranchFn: func(string) error { return nil }, RebaseOntoFn: func(string, string, string, git.RebaseOpts) error { return nil }, @@ -60,11 +60,11 @@ func newApplyMock(gitDir string, branchSHAs map[string]string) *git.MockOps { LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { return []git.CommitInfo{{SHA: "commit-1"}, {SHA: "commit-2"}}, nil }, - CherryPickFn: func([]string) error { return 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 }, + ResetHardFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + RebaseAbortFn: func() error { return nil }, } } @@ -795,14 +795,14 @@ func TestContinueApply_MultiStackFindsCorrectStack(t *testing.T) { // Create a state file pointing at Stack 1 (index 1) state := &StateFile{ - SchemaVersion: 1, - StackName: "main", - StackIndex: 1, // The correct stack is at index 1 - Phase: PhaseConflict, - ConflictBranch: "A", - ConflictType: "rebase", + SchemaVersion: 1, + StackName: "main", + StackIndex: 1, // The correct stack is at index 1 + Phase: PhaseConflict, + ConflictBranch: "A", + ConflictType: "rebase", RemainingBranches: []string{"B", "C"}, - OriginalRefs: map[string]string{"B": "sha-A", "C": "sha-B"}, + OriginalRefs: map[string]string{"B": "sha-A", "C": "sha-B"}, } require.NoError(t, SaveState(gitDir, state)) @@ -1044,13 +1044,13 @@ func TestContinueApply(t *testing.T) { // Write a conflict state file stateFile := &StateFile{ - SchemaVersion: 1, - StackName: "main", - StackIndex: 0, - Phase: "conflict", - ConflictBranch: "B", - RemainingBranches: []string{"C"}, - OriginalBranch: "A", + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: "conflict", + ConflictBranch: "B", + RemainingBranches: []string{"C"}, + OriginalBranch: "A", OriginalRefs: map[string]string{ "A": "sha-A", "B": "sha-B", @@ -1064,9 +1064,9 @@ func TestContinueApply(t *testing.T) { var checkoutCalls []string mock := &git.MockOps{ - GitDirFn: func() (string, error) { return gitDir, nil }, - CurrentBranchFn: func() (string, error) { return "B", nil }, - BranchExistsFn: func(string) bool { return true }, + 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 }, RebaseContinueFn: func(git.RebaseOpts) error { rebaseContinueCalled = true @@ -1081,8 +1081,8 @@ func TestContinueApply(t *testing.T) { return nil }, IsAncestorFn: func(a, d string) (bool, error) { return false, nil }, - MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, - RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + MergeBaseFn: func(a, b string) (string, error) { return "merge-base", nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, } restore := git.SetOps(mock) @@ -1203,6 +1203,220 @@ func TestUnwind_AbortsActiveRebase(t *testing.T) { assert.False(t, StateExists(gitDir)) } +// ─── Unwind with active cherry-pick ───────────────────────────────────────── + +// A fold-down conflict leaves an in-progress cherry-pick with an unmerged +// index. Unwind must abort it (git cherry-pick --abort) before restoring +// branches, otherwise the restore checkouts fail on the unmerged index. +func TestUnwind_AbortsActiveCherryPick(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + + snapshotMock := &git.MockOps{ + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + } + restore := git.SetOps(snapshotMock) + snapshot, err := BuildSnapshot(&s) + require.NoError(t, err) + restore() + + require.NoError(t, SaveState(gitDir, &StateFile{ + SchemaVersion: 1, Phase: PhaseConflict, ConflictType: "cherry_pick", Snapshot: snapshot, + })) + + var cherryPickAbortCalled bool + var rebaseAbortCalled bool + mock := &git.MockOps{ + IsRebaseInProgressFn: func() bool { return false }, + IsCherryPickInProgressFn: func() bool { return true }, + RebaseAbortFn: func() error { rebaseAbortCalled = true; return nil }, + CherryPickAbortFn: func() error { cherryPickAbortCalled = true; return nil }, + BranchExistsFn: func(string) bool { return true }, + CheckoutBranchFn: func(string) error { return nil }, + ResetHardFn: func(string) error { return nil }, + CreateBranchFn: func(string, string) error { return nil }, + } + + restore = git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err = Unwind(cfg, gitDir, snapshot, 0, sf, nil) + require.NoError(t, err) + assert.True(t, cherryPickAbortCalled, "CherryPickAbort should be called when a cherry-pick is in progress") + assert.False(t, rebaseAbortCalled, "RebaseAbort should not be called when no rebase is in progress") + assert.False(t, StateExists(gitDir)) +} + +// ─── ContinueApply: subsequent conflict after fold-down is a rebase ───────── + +// After resolving an initial fold-down (cherry-pick) conflict, the cascading +// rebase over the remaining branches may itself conflict. That conflict is a +// rebase, so ContinueApply must update ConflictType from "cherry_pick" to +// "rebase" — otherwise the next --continue wrongly calls CherryPickContinue +// and fails, stranding the user. +func TestContinueApply_SubsequentConflictBecomesRebase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + sf := writeTestStackFile(t, gitDir, s) + _ = sf + + // State as written when a fold-down of B into A conflicts on cherry-pick. + // B is still present in the stack metadata (it is removed only after the + // cherry-pick succeeds in ContinueApply). + state := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: PhaseConflict, + ConflictType: "cherry_pick", + ConflictBranch: "B", + FoldBranch: "B", + FoldTarget: "A", + RemainingBranches: []string{"A", "C"}, + OriginalBranch: "A", + OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, + } + require.NoError(t, SaveState(gitDir, state)) + + mock := newApplyMock(gitDir, map[string]string{ + "main": "sha-main", "A": "sha-A", "B": "sha-B", "C": "sha-C", + }) + // The user resolved the cherry-pick; --continue finishes it cleanly. + mock.CherryPickContinueFn = func() error { return nil } + // A rebases cleanly onto main; C then conflicts. + mock.RebaseOntoFn = func(newBase, oldBase, branch string, opts git.RebaseOpts) error { + if branch == "C" { + return assert.AnError + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return []string{"c.go"}, nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.Error(t, err) + + got, loadErr := LoadState(gitDir) + require.NoError(t, loadErr) + require.NotNil(t, got) + assert.Equal(t, PhaseConflict, got.Phase) + assert.Equal(t, "rebase", got.ConflictType, "subsequent cascade conflict must be recorded as a rebase") + assert.Equal(t, "C", got.ConflictBranch) +} + +// Regression test for the review on PR #167: after an initial fold-down +// (cherry-pick) conflict is resolved, a subsequent cascade rebase conflict must +// persist the fold-branch removal to disk. Otherwise the next --continue +// re-reads stale on-disk metadata and — because ConflictType is now "rebase" — +// skips the fold-removal step, silently resurrecting the folded branch as a +// phantom entry once recovery completes. +func TestContinueApply_FoldThenCascadeConflict_DoesNotResurrectFoldedBranch(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "A"}, + {Branch: "B"}, + {Branch: "C"}, + }, + } + + gitDir := t.TempDir() + writeTestStackFile(t, gitDir, s) + + // State written by ApplyPlan when the fold-down of B into A conflicts on + // cherry-pick. B is still present in the on-disk metadata at this point. + state := &StateFile{ + SchemaVersion: 1, + StackName: "main", + StackIndex: 0, + Phase: PhaseConflict, + ConflictType: "cherry_pick", + ConflictBranch: "B", + FoldBranch: "B", + FoldTarget: "A", + RemainingBranches: []string{"A", "C"}, + OriginalBranch: "A", + OriginalRefs: map[string]string{"A": "sha-main", "C": "sha-A-old"}, + } + 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 } + // 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 { + return assert.AnError + } + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return []string{"c.go"}, nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + defer cfg.Out.Close() + defer cfg.Err.Close() + + // First --continue: finishes the fold, then conflicts rebasing C. + err := ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.Error(t, err) + + // The fold-branch removal must already be persisted on disk, even though + // the cascade hit a conflict. + afterFirst, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Equal(t, -1, afterFirst.Stacks[0].IndexOf("B"), + "folded branch B must not be present on disk after the cascade conflict") + + // Second --continue: rebase resolves and recovery completes. + err = ContinueApply(cfg, gitDir, noopUpdateBaseSHAs) + require.NoError(t, err) + + final, err := stack.Load(gitDir) + require.NoError(t, err) + names := make([]string, len(final.Stacks[0].Branches)) + for i, b := range final.Stacks[0].Branches { + names[i] = b.Branch + } + assert.Equal(t, []string{"A", "C"}, names, + "folded branch B must stay removed after recovery completes") + assert.False(t, StateExists(gitDir), "state should be cleared after successful recovery") +} + // ─── Unwind restores renamed branch ───────────────────────────────────────── func TestUnwind_RestoresRenamedBranch(t *testing.T) { diff --git a/internal/pr/template.go b/internal/pr/template.go index d9ccfaed..6c08f51d 100644 --- a/internal/pr/template.go +++ b/internal/pr/template.go @@ -1,35 +1,18 @@ package pr import ( - "os" - "path/filepath" "strings" -) -// templatePaths lists the candidate locations for a pull request template. -var templatePaths = []string{ - ".github/pull_request_template.md", - ".github/PULL_REQUEST_TEMPLATE.md", - "pull_request_template.md", - "PULL_REQUEST_TEMPLATE.md", - "docs/pull_request_template.md", - "docs/PULL_REQUEST_TEMPLATE.md", -} + "github.com/cli/cli/v2/pkg/githubtemplate" +) // FindTemplate searches the repository root for a default pull request -// template and returns its content. Returns an empty string if no template -// is found or cannot be read. +// template and returns its content with any YAML front-matter stripped. +// It returns an empty string if no template is found. func FindTemplate(repoRoot string) string { - for _, candidate := range templatePaths { - path := filepath.Join(repoRoot, candidate) - data, err := os.ReadFile(path) - if err != nil { - continue - } - content := strings.TrimSpace(string(data)) - if content != "" { - return content - } + path := githubtemplate.FindLegacy(repoRoot, "pull_request_template") + if path == "" { + return "" } - return "" + return strings.TrimSpace(string(githubtemplate.ExtractContents(path))) } diff --git a/internal/pr/template_test.go b/internal/pr/template_test.go index 3f4ca7c7..567d8503 100644 --- a/internal/pr/template_test.go +++ b/internal/pr/template_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // writeTemplate is a test helper that creates a file with the given content, @@ -76,3 +77,49 @@ func TestFindTemplate_UpperCase(t *testing.T) { got := FindTemplate(root) assert.Equal(t, "UPPER template", got) } + +// TestFindTemplate_IgnoresSymlink verifies that a symlinked PR template is not +// followed: FindTemplate ignores it rather than reading through to the file the +// symlink points at. +func TestFindTemplate_IgnoresSymlink(t *testing.T) { + root := t.TempDir() + + // A file outside the repository that the symlinked template points at. + linked := filepath.Join(t.TempDir(), "linked.txt") + require.NoError(t, os.WriteFile(linked, []byte("LINKED_FILE_CONTENTS"), 0o600)) + + ghDir := filepath.Join(root, ".github") + require.NoError(t, os.MkdirAll(ghDir, 0o755)) + link := filepath.Join(ghDir, "pull_request_template.md") + if err := os.Symlink(linked, link); err != nil { + t.Skipf("symlinks not supported on this platform: %v", err) + } + + got := FindTemplate(root) + assert.Empty(t, got, "symlinked PR template must be ignored") + assert.NotContains(t, got, "LINKED_FILE_CONTENTS", "symlink target contents must not be read") +} + +// TestFindTemplate_StripsFrontMatter documents that discovery now delegates to +// cli/cli's githubtemplate package, which strips leading YAML front-matter from +// the template (matching `gh pr create`). +func TestFindTemplate_StripsFrontMatter(t *testing.T) { + root := t.TempDir() + writeTemplate(t, filepath.Join(root, ".github", "pull_request_template.md"), + []byte("---\nname: PR\nabout: test\n---\n\n## Description\n\nBody text.")) + + got := FindTemplate(root) + assert.Equal(t, "## Description\n\nBody text.", got) + assert.NotContains(t, got, "name: PR", "YAML front-matter should be stripped") +} + +// TestFindTemplate_HyphenatedName documents the broadened filename matching that +// comes with reusing githubtemplate.FindLegacy (hyphenated variants are now +// recognized, closer to GitHub's own acceptance). +func TestFindTemplate_HyphenatedName(t *testing.T) { + root := t.TempDir() + writeTemplate(t, filepath.Join(root, ".github", "pull-request-template.md"), []byte("Hyphenated template")) + + got := FindTemplate(root) + assert.Equal(t, "Hyphenated template", got) +} diff --git a/internal/stack/schema.json b/internal/stack/schema.json index 3e9f2abd..49547bc1 100644 --- a/internal/stack/schema.json +++ b/internal/stack/schema.json @@ -28,15 +28,11 @@ "properties": { "id": { "type": "string", - "description": "Identifier for this stack, populated from the API when available." + "description": "Global identifier for this stack, populated from the API when available." }, - "prefix": { - "type": "string", - "description": "Branch name prefix for the stack (e.g. 'myfeature')." - }, - "numbered": { - "type": "boolean", - "description": "Whether to use auto-incrementing numbered branch names." + "number": { + "type": "integer", + "description": "Repo-scoped number identifying this stack, displayed in the GitHub UI. Used as the primary way to reference a stack." }, "trunk": { "$ref": "#/$defs/branchRef", diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 7e4043af..c03205ab 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -43,8 +43,7 @@ type BranchRef struct { // Stack represents a single stack of branches. type Stack struct { ID string `json:"id,omitempty"` - Prefix string `json:"prefix,omitempty"` - Numbered bool `json:"numbered,omitempty"` + Number int `json:"number,omitempty"` Trunk BranchRef `json:"trunk"` Branches []BranchRef `json:"branches"` } @@ -193,6 +192,39 @@ func (s *Stack) IsFullyMerged() bool { return len(s.Branches) > 0 } +// NearestSurvivingBranch returns the branch nearest to target within the ordered +// branch-name list `order` for which `survives` reports true, preferring the +// neighbor above (later in the slice, away from the trunk) and then the neighbor +// below (earlier in the slice, toward the trunk). +// +// It returns "" when target is not present in order, or when no other branch in +// order survives. Callers layer their own fallback and any name translation +// around this search. It is shared by the checkout-branch selection in +// `gh stack modify` and `gh stack sync`. +func NearestSurvivingBranch(order []string, target string, survives func(string) bool) string { + pos := -1 + for i, name := range order { + if name == target { + pos = i + break + } + } + if pos < 0 { + return "" + } + for i := pos + 1; i < len(order); i++ { + if survives(order[i]) { + return order[i] + } + } + for i := pos - 1; i >= 0; i-- { + if survives(order[i]) { + return order[i] + } + } + return "" +} + // StackFile represents the JSON file stored in .git/gh-stack. type StackFile struct { SchemaVersion int `json:"schemaVersion"` diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go index 987f25c3..73ddc408 100644 --- a/internal/stack/stack_test.go +++ b/internal/stack/stack_test.go @@ -235,7 +235,7 @@ func TestLoad_Save_RoundTrip(t *testing.T) { Stacks: []Stack{ { ID: "s1", - Prefix: "feat", + Number: 7, Trunk: BranchRef{Branch: "main", Head: "abc123"}, Branches: []BranchRef{ {Branch: "b1", Head: "def456", Base: "abc123"}, @@ -256,7 +256,7 @@ func TestLoad_Save_RoundTrip(t *testing.T) { s := loaded.Stacks[0] assert.Equal(t, "s1", s.ID) - assert.Equal(t, "feat", s.Prefix) + assert.Equal(t, 7, s.Number) assert.Equal(t, "main", s.Trunk.Branch) assert.Equal(t, "abc123", s.Trunk.Head) require.Len(t, s.Branches, 2) @@ -625,3 +625,34 @@ func TestIsFullyMerged_NotAffectedByQueued(t *testing.T) { assert.False(t, s.IsFullyMerged()) }) } + +func TestNearestSurvivingBranch(t *testing.T) { + // survivesIn returns a predicate reporting membership in the given set. + survivesIn := func(names ...string) func(string) bool { + set := make(map[string]bool, len(names)) + for _, n := range names { + set[n] = true + } + return func(name string) bool { return set[name] } + } + + tests := []struct { + name string + order []string + target string + survives func(string) bool + want string + }{ + {"prefers neighbor above", []string{"a", "b", "c"}, "b", survivesIn("a", "c"), "c"}, + {"falls to neighbor below", []string{"a", "b", "c"}, "c", survivesIn("a", "b"), "b"}, + {"skips dead neighbors above", []string{"a", "b", "c", "d"}, "b", survivesIn("a", "d"), "d"}, + {"target not in order", []string{"a", "b"}, "z", survivesIn("a", "b"), ""}, + {"no other survives", []string{"a", "b", "c"}, "b", survivesIn("b"), ""}, + {"empty order", nil, "a", survivesIn("a"), ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, NearestSurvivingBranch(tt.order, tt.target, tt.survives)) + }) + } +} diff --git a/internal/tui/checkoutview/data.go b/internal/tui/checkoutview/data.go new file mode 100644 index 00000000..4bd2e02b --- /dev/null +++ b/internal/tui/checkoutview/data.go @@ -0,0 +1,369 @@ +// Package checkoutview renders the interactive stack picker shown by +// `gh stack checkout` when no target is given. It reconciles the locally +// tracked stacks with the stacks returned by the Stacks REST API into a single +// table, labels each as Local or Remote, and lets the user filter, search, and +// select one to check out. +package checkoutview + +import ( + "fmt" + "sort" + "strconv" + "time" + + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +// StackType classifies where a stack lives. +type StackType int + +const ( + // TypeLocal means the stack is present in the local stack file. It may also + // be tracked on the remote; per the picker's simplification, anything + // available locally is "Local". + TypeLocal StackType = iota + // TypeRemote means the stack exists only on the remote. + TypeRemote +) + +// String returns the human-readable label for the type column. +func (t StackType) String() string { + if t == TypeRemote { + return "Remote" + } + return "Local" +} + +// StatusCounts summarizes a stack's composition for the status bar. Each field +// is a count of branches/PRs in that state. +type StatusCounts struct { + Merged int // merged PRs (purple) + Open int // open, non-merged PRs (green) + Closed int // closed, non-merged PRs (red) + Unpushed int // branches with no PR yet (gray) +} + +// Total returns the number of branches/PRs represented. +func (c StatusCounts) Total() int { + return c.Merged + c.Open + c.Closed + c.Unpushed +} + +// fullyMerged reports whether every entry is a merged PR (nothing open, closed, +// or unpushed). Such stacks can no longer be added to and are filtered out. +func (c StatusCounts) fullyMerged() bool { + return c.Merged > 0 && c.Open == 0 && c.Closed == 0 && c.Unpushed == 0 +} + +// StackRow is a single reconciled entry shown in the checkout picker. +type StackRow struct { + Number int // stack number; 0 when unknown (local-only, not pushed) + Type StackType + BottomBranch string + TopBranch string + Base string + Status StatusCounts + Created time.Time + HasCreated bool + + // Branches is the full ordered list of branch names in the stack. Only + // BottomBranch and TopBranch are displayed; the complete list exists so + // search can match any branch, including mid-stack ones. + Branches []string + + // LocalStack points at the local stack when Type == TypeLocal, so the caller + // can check out its branches directly without cloning. It is nil for + // remote-only rows, which are cloned by stack number instead. + LocalStack *stack.Stack +} + +// branchSep joins the bottom and top branch names in the Branches column, +// mirroring Git's A...B compare syntax. +const branchSep = "..." + +// Summary returns "bottom...top" (or a single branch when the stack has one +// branch, or the ends coincide). +func (r StackRow) Summary() string { + switch { + case r.BottomBranch == "" && r.TopBranch == "": + return "" + case r.TopBranch == "" || r.BottomBranch == r.TopBranch: + return r.BottomBranch + case r.BottomBranch == "": + return r.TopBranch + default: + return r.BottomBranch + branchSep + r.TopBranch + } +} + +// NumberDisplay renders the stack number, or "—" when it is unknown. +func (r StackRow) NumberDisplay() string { + if r.Number == 0 { + return "—" + } + return strconv.Itoa(r.Number) +} + +// CreatedDisplay renders the creation time as a compact age, or "—" when +// unknown. +func (r StackRow) CreatedDisplay() string { + if !r.HasCreated { + return "—" + } + return relativeTime(r.Created) +} + +// BuildRows reconciles the local stacks with the remote stacks into the ordered +// list shown in the picker. Local stacks own the "Local" type even when they +// are tracked on the remote; remote stacks with no local match are "Remote". +// Fully-merged stacks (every PR merged) are omitted. The local slice must be the +// caller's live stack slice, since rows retain pointers into it. +func BuildRows(local []stack.Stack, remote []github.RemoteStack) []StackRow { + remoteByNumber := make(map[int]*github.RemoteStack, len(remote)) + remoteByID := make(map[int]*github.RemoteStack, len(remote)) + for i := range remote { + rs := &remote[i] + if rs.Number != 0 { + remoteByNumber[rs.Number] = rs + } + remoteByID[rs.ID] = rs + } + + matched := make(map[*github.RemoteStack]bool, len(remote)) + rows := make([]StackRow, 0, len(local)+len(remote)) + + // Local stacks first — they own the "Local" type even when tracked. + for i := range local { + ls := &local[i] + rs := matchRemote(ls, remoteByNumber, remoteByID) + if rs != nil { + matched[rs] = true + } + if row, ok := localRow(ls, rs); ok { + rows = append(rows, row) + } + } + + // Remote-only stacks. + for i := range remote { + rs := &remote[i] + if matched[rs] { + continue + } + if row, ok := remoteRow(rs); ok { + rows = append(rows, row) + } + } + + sortRows(rows) + return rows +} + +// matchRemote finds the remote stack that corresponds to a local stack, by +// stack number first and then by the remote id stored (as a string) locally. +func matchRemote(ls *stack.Stack, byNumber, byID map[int]*github.RemoteStack) *github.RemoteStack { + if ls.Number != 0 { + if rs := byNumber[ls.Number]; rs != nil { + return rs + } + } + if ls.ID != "" { + if id, err := strconv.Atoi(ls.ID); err == nil { + if rs := byID[id]; rs != nil { + return rs + } + } + } + return nil +} + +// localRow builds the row for a local stack, enriching it with live remote data +// (number, base, created, PR states) when a matching remote stack is provided. +func localRow(ls *stack.Stack, rs *github.RemoteStack) (StackRow, bool) { + row := StackRow{ + Type: TypeLocal, + Number: ls.Number, + Base: ls.Trunk.Branch, + LocalStack: ls, + } + if len(ls.Branches) > 0 { + row.BottomBranch = ls.Branches[0].Branch + row.TopBranch = ls.Branches[len(ls.Branches)-1].Branch + } + row.Branches = make([]string, len(ls.Branches)) + for i := range ls.Branches { + row.Branches[i] = ls.Branches[i].Branch + } + + if rs != nil { + if rs.Number != 0 { + row.Number = rs.Number + } + if rs.Base.Ref != "" { + row.Base = rs.Base.Ref + } + if t, ok := parseTime(rs.CreatedAt); ok { + row.Created, row.HasCreated = t, true + } + row.Status = statusFromLocalTracked(ls, rs) + } else { + row.Status = statusFromLocal(ls) + } + + if row.Status.fullyMerged() { + return StackRow{}, false + } + return row, true +} + +// remoteRow builds the row for a remote-only stack from its API details. +func remoteRow(rs *github.RemoteStack) (StackRow, bool) { + if len(rs.PRDetails) == 0 { + return StackRow{}, false + } + row := StackRow{ + Number: rs.Number, + Type: TypeRemote, + Base: rs.Base.Ref, + BottomBranch: rs.PRDetails[0].Head.Ref, + TopBranch: rs.PRDetails[len(rs.PRDetails)-1].Head.Ref, + } + row.Branches = make([]string, len(rs.PRDetails)) + for i, p := range rs.PRDetails { + row.Branches[i] = p.Head.Ref + } + if t, ok := parseTime(rs.CreatedAt); ok { + row.Created, row.HasCreated = t, true + } + + var c StatusCounts + for _, p := range rs.PRDetails { + classifyRemotePR(p, &c) + } + row.Status = c + + if c.fullyMerged() { + return StackRow{}, false + } + return row, true +} + +// statusFromLocalTracked derives status counts for a tracked stack, preferring +// the remote's live PR states and counting local branches with no PR as +// unpushed. It produces one entry per local branch. +func statusFromLocalTracked(ls *stack.Stack, rs *github.RemoteStack) StatusCounts { + remoteByNum := make(map[int]github.RemoteStackPR, len(rs.PRDetails)) + for _, p := range rs.PRDetails { + remoteByNum[p.Number] = p + } + + var c StatusCounts + for i := range ls.Branches { + b := &ls.Branches[i] + if b.PullRequest != nil && b.PullRequest.Number != 0 { + if p, ok := remoteByNum[b.PullRequest.Number]; ok { + classifyRemotePR(p, &c) + continue + } + // Tracked locally but absent from the remote stack — fall back to the + // local merged flag. + if b.IsMerged() { + c.Merged++ + } else { + c.Open++ + } + continue + } + c.Unpushed++ + } + return c +} + +// statusFromLocal derives status counts for an untracked local stack from its +// local PR references alone. +func statusFromLocal(ls *stack.Stack) StatusCounts { + var c StatusCounts + for i := range ls.Branches { + b := &ls.Branches[i] + if b.PullRequest != nil && b.PullRequest.Number != 0 { + if b.IsMerged() { + c.Merged++ + } else { + c.Open++ + } + continue + } + c.Unpushed++ + } + return c +} + +// classifyRemotePR increments the count matching a remote PR's state. +func classifyRemotePR(p github.RemoteStackPR, c *StatusCounts) { + switch { + case p.IsMerged(): + c.Merged++ + case p.State == "closed": + c.Closed++ + default: + c.Open++ + } +} + +// sortRows orders rows newest-first: local-only stacks with no number surface +// first (active in-progress work), then higher stack numbers (newer) before +// lower ones. Ties break by creation time then summary for determinism. +func sortRows(rows []StackRow) { + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if (a.Number == 0) != (b.Number == 0) { + return a.Number == 0 + } + if a.Number != b.Number { + return a.Number > b.Number + } + if a.HasCreated != b.HasCreated { + return a.HasCreated + } + if a.HasCreated && b.HasCreated && !a.Created.Equal(b.Created) { + return a.Created.After(b.Created) + } + return a.Summary() < b.Summary() + }) +} + +// parseTime parses an RFC3339 timestamp, reporting whether it succeeded. +func parseTime(s string) (time.Time, bool) { + if s == "" { + return time.Time{}, false + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, true + } + return time.Time{}, false +} + +// relativeTime formats t as a compact age like "15m ago", "3d ago", or +// "1mo ago". +func relativeTime(t time.Time) string { + d := time.Since(t) + if d < 0 { + d = 0 + } + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dw ago", int(d.Hours()/24/7)) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30)) + default: + return fmt.Sprintf("%dy ago", int(d.Hours()/24/365)) + } +} diff --git a/internal/tui/checkoutview/data_test.go b/internal/tui/checkoutview/data_test.go new file mode 100644 index 00000000..31aacf31 --- /dev/null +++ b/internal/tui/checkoutview/data_test.go @@ -0,0 +1,331 @@ +package checkoutview + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +func mergedAt(t time.Time) *string { + s := t.UTC().Format(time.RFC3339) + return &s +} + +func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +// findRow returns the row with the given number (or a zero-value row) for +// assertions. +func findRow(rows []StackRow, number int) (StackRow, bool) { + for _, r := range rows { + if r.Number == number { + return r, true + } + } + return StackRow{}, false +} + +func TestBuildRows_LocalOnly_Unpushed(t *testing.T) { + local := []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-a"}, + {Branch: "feat-b"}, + }, + }} + + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + + r := rows[0] + assert.Equal(t, TypeLocal, r.Type) + assert.Equal(t, 0, r.Number) + assert.Equal(t, "—", r.NumberDisplay()) + assert.Equal(t, "feat-a...feat-b", r.Summary()) + assert.Equal(t, "main", r.Base) + assert.Equal(t, StatusCounts{Unpushed: 2}, r.Status) + assert.False(t, r.HasCreated) + assert.Equal(t, "—", r.CreatedDisplay()) + require.NotNil(t, r.LocalStack) + assert.Same(t, &local[0], r.LocalStack) +} + +func TestBuildRows_RemoteOnly(t *testing.T) { + now := time.Now() + remote := []github.RemoteStack{{ + ID: 200, + Number: 55, + Base: github.RemoteStackBase{Ref: "main"}, + CreatedAt: rfc3339(now.Add(-3 * time.Hour)), + PRDetails: []github.RemoteStackPR{ + {Number: 7, State: "open", Head: github.RemoteStackPRHead{Ref: "refactor-1"}}, + {Number: 8, State: "closed", Head: github.RemoteStackPRHead{Ref: "refactor-2"}}, + {Number: 9, State: "open", Head: github.RemoteStackPRHead{Ref: "refactor-3"}}, + }, + }} + + rows := BuildRows(nil, remote) + require.Len(t, rows, 1) + + r := rows[0] + assert.Equal(t, TypeRemote, r.Type) + assert.Equal(t, 55, r.Number) + assert.Equal(t, "refactor-1...refactor-3", r.Summary()) + assert.Equal(t, "main", r.Base) + assert.Equal(t, StatusCounts{Open: 2, Closed: 1}, r.Status) + assert.True(t, r.HasCreated) + assert.Nil(t, r.LocalStack) +} + +func TestBuildRows_TrackedStack_IsLocalWithLiveStatus(t *testing.T) { + now := time.Now() + // Local stack tracked on remote (matched by number and id). It has three + // branches: one merged PR, one open PR, and one unpushed branch. + local := []stack.Stack{{ + Number: 42, + ID: "100", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "api-1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "api-2", PullRequest: &stack.PullRequestRef{Number: 2}}, + {Branch: "api-3"}, + }, + }} + remote := []github.RemoteStack{{ + ID: 100, + Number: 42, + Base: github.RemoteStackBase{Ref: "trunk"}, + CreatedAt: rfc3339(now.Add(-72 * time.Hour)), + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "api-1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "api-2"}}, + }, + }} + + rows := BuildRows(local, remote) + require.Len(t, rows, 1, "tracked stack must appear once, not duplicated") + + r := rows[0] + assert.Equal(t, TypeLocal, r.Type, "available locally means Local even when tracked") + assert.Equal(t, 42, r.Number) + // Base and created come from the live remote data. + assert.Equal(t, "trunk", r.Base) + assert.True(t, r.HasCreated) + // One box per local branch: merged (remote), open (remote), unpushed (no PR). + assert.Equal(t, StatusCounts{Merged: 1, Open: 1, Unpushed: 1}, r.Status) +} + +func TestBuildRows_MatchByID_WhenNumberMissing(t *testing.T) { + // Local stack has no number yet (0) but stores the remote id as a string. + local := []stack.Stack{{ + Number: 0, + ID: "500", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + }} + remote := []github.RemoteStack{{ + ID: 500, + Number: 88, + Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "b1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "b2"}}, + }, + }} + + rows := BuildRows(local, remote) + require.Len(t, rows, 1, "id match must dedupe local and remote") + assert.Equal(t, TypeLocal, rows[0].Type) + assert.Equal(t, 88, rows[0].Number, "number is backfilled from the matched remote") +} + +func TestBuildRows_FiltersFullyMergedStacks(t *testing.T) { + now := time.Now() + remote := []github.RemoteStack{ + { + ID: 1, Number: 1, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{ + {Number: 10, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "m1"}}, + {Number: 11, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "m2"}}, + }, + }, + { + ID: 2, Number: 2, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{ + {Number: 20, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "x"}}, + {Number: 21, State: "open", Head: github.RemoteStackPRHead{Ref: "y"}}, + }, + }, + } + + rows := BuildRows(nil, remote) + require.Len(t, rows, 1, "fully-merged stack #1 must be filtered out") + assert.Equal(t, 2, rows[0].Number) +} + +func TestBuildRows_LocalFullyMergedIsFiltered(t *testing.T) { + local := []stack.Stack{{ + Number: 9, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + }} + + rows := BuildRows(local, nil) + assert.Empty(t, rows, "a local stack with every branch merged is filtered") +} + +func TestBuildRows_SortOrder(t *testing.T) { + now := time.Now() + local := []stack.Stack{{ + Number: 0, // local-only, unpushed -> sorts first + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "wip"}}, + }} + remote := []github.RemoteStack{ + {ID: 1, Number: 10, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{{Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "a"}}}}, + {ID: 2, Number: 30, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{{Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "b"}}}}, + } + + rows := BuildRows(local, remote) + require.Len(t, rows, 3) + // Local-only (number 0) first, then higher numbers before lower. + assert.Equal(t, 0, rows[0].Number) + assert.Equal(t, 30, rows[1].Number) + assert.Equal(t, 10, rows[2].Number) +} + +func TestBuildRows_ClosedNonMergedStackIsKept(t *testing.T) { + remote := []github.RemoteStack{{ + ID: 1, Number: 1, Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 10, State: "closed", Head: github.RemoteStackPRHead{Ref: "x"}}, + {Number: 11, State: "closed", Head: github.RemoteStackPRHead{Ref: "y"}}, + }, + }} + rows := BuildRows(nil, remote) + require.Len(t, rows, 1, "closed-but-not-merged stacks stay visible") + assert.Equal(t, StatusCounts{Closed: 2}, rows[0].Status) +} + +func TestBuildRows_PopulatesAllBranchesForSearch(t *testing.T) { + local := []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a"}, {Branch: "b"}, {Branch: "c"}, + }, + }} + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + assert.Equal(t, []string{"a", "b", "c"}, rows[0].Branches, "local rows carry every branch name for search") + + remote := []github.RemoteStack{{ + ID: 1, Number: 9, Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "r1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "r2"}}, + {Number: 3, State: "open", Head: github.RemoteStackPRHead{Ref: "r3"}}, + }, + }} + rrows := BuildRows(nil, remote) + require.Len(t, rrows, 1) + assert.Equal(t, []string{"r1", "r2", "r3"}, rrows[0].Branches, "remote rows carry every branch name for search") +} + +func TestBuildRows_UntrackedLocalWithMergedPRFlag(t *testing.T) { + local := []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + }} + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + assert.Equal(t, StatusCounts{Merged: 1, Open: 1}, rows[0].Status) +} + +func TestStackRow_Summary(t *testing.T) { + tests := []struct { + name string + row StackRow + expect string + }{ + {"two branches", StackRow{BottomBranch: "a", TopBranch: "b"}, "a...b"}, + {"single branch", StackRow{BottomBranch: "a", TopBranch: "a"}, "a"}, + {"only bottom", StackRow{BottomBranch: "a"}, "a"}, + {"empty", StackRow{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, tt.row.Summary()) + }) + } +} + +func TestStatusCounts_FullyMerged(t *testing.T) { + assert.True(t, StatusCounts{Merged: 3}.fullyMerged()) + assert.False(t, StatusCounts{}.fullyMerged()) + assert.False(t, StatusCounts{Merged: 1, Open: 1}.fullyMerged()) + assert.False(t, StatusCounts{Unpushed: 2}.fullyMerged()) + assert.False(t, StatusCounts{Merged: 1, Closed: 1}.fullyMerged()) +} + +func TestDistribute(t *testing.T) { + // Fits within max: unchanged (one square per branch). + assert.Equal(t, []int{1, 2, 0, 1}, distribute([]int{1, 2, 0, 1}, 10)) + + // Exceeds max: downsample to sum exactly max, preserving proportions. + got := distribute([]int{10, 10, 0, 0}, 10) + sum := 0 + for _, n := range got { + sum += n + } + assert.Equal(t, 10, sum) + assert.Equal(t, []int{5, 5, 0, 0}, got) +} + +func TestParseTime(t *testing.T) { + _, ok := parseTime("") + assert.False(t, ok) + _, ok = parseTime("not-a-time") + assert.False(t, ok) + tm, ok := parseTime("2024-01-02T03:04:05Z") + require.True(t, ok) + assert.Equal(t, 2024, tm.Year()) +} + +func TestRelativeTime(t *testing.T) { + now := time.Now() + tests := []struct { + name string + at time.Time + expect string + }{ + {"just now", now.Add(-10 * time.Second), "just now"}, + {"minutes", now.Add(-15 * time.Minute), "15m ago"}, + {"hours", now.Add(-3 * time.Hour), "3h ago"}, + {"days", now.Add(-3 * 24 * time.Hour), "3d ago"}, + {"weeks", now.Add(-14 * 24 * time.Hour), "2w ago"}, + {"months", now.Add(-60 * 24 * time.Hour), "2mo ago"}, + {"future clamps", now.Add(1 * time.Hour), "just now"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, relativeTime(tt.at)) + }) + } +} diff --git a/internal/tui/checkoutview/model.go b/internal/tui/checkoutview/model.go new file mode 100644 index 00000000..d500d61c --- /dev/null +++ b/internal/tui/checkoutview/model.go @@ -0,0 +1,622 @@ +package checkoutview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" +) + +// tab identifies the active filter tab. +type tab int + +const ( + tabAll tab = iota + tabLocal + tabRemote +) + +func (t tab) String() string { + switch t { + case tabLocal: + return "Local" + case tabRemote: + return "Remote" + default: + return "All" + } +} + +// keyMap holds the picker's key bindings. +type keyMap struct { + Up key.Binding + Down key.Binding + Select key.Binding + NextTab key.Binding + PrevTab key.Binding + Search key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "ctrl+p")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+n")), + Select: key.NewBinding(key.WithKeys("enter")), + NextTab: key.NewBinding(key.WithKeys("tab", "right")), + PrevTab: key.NewBinding(key.WithKeys("shift+tab", "left")), + Search: key.NewBinding(key.WithKeys("/")), + Quit: key.NewBinding(key.WithKeys("esc", "q")), +} + +// Model is the Bubble Tea model for the interactive checkout stack picker. +type Model struct { + rows []StackRow // all reconciled rows (stable order) + filtered []StackRow // rows matching the active tab + search query + + tab tab + query string + searching bool + + cursor int + scrollOffset int + width int + height int + + result StackRow + hasResult bool + cancelled bool +} + +// New creates a picker model over the given reconciled rows. +func New(rows []StackRow) Model { + m := Model{rows: rows} + m.applyFilter() + return m +} + +// Result returns the selected row and whether one was chosen (Enter). It is +// false when the user cancelled or nothing was selectable. +func (m Model) Result() (StackRow, bool) { + return m.result, m.hasResult +} + +// Cancelled reports whether the user dismissed the picker without selecting. +func (m Model) Cancelled() bool { + return m.cancelled +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.ensureVisible() + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case msg.Type == tea.KeyCtrlC: + m.cancelled = true + return m, tea.Quit + + case key.Matches(msg, keys.Up): + m.moveCursor(-1) + return m, nil + + case key.Matches(msg, keys.Down): + m.moveCursor(1) + return m, nil + + case key.Matches(msg, keys.Select): + if row, ok := m.current(); ok { + m.result = row + m.hasResult = true + return m, tea.Quit + } + return m, nil + + case key.Matches(msg, keys.NextTab): + m.cycleTab(1) + return m, nil + + case key.Matches(msg, keys.PrevTab): + m.cycleTab(-1) + return m, nil + + case key.Matches(msg, keys.Search) && !m.searching: + m.searching = true + // Entering search adds the search line, shrinking the body; keep the + // cursor visible so Enter can't select an off-screen row. + m.ensureVisible() + return m, nil + + case !m.searching && key.Matches(msg, keys.Quit): + m.cancelled = true + return m, tea.Quit + } + + if m.searching { + return m.updateSearch(msg) + } + return m, nil +} + +// updateSearch handles text entry while the search field is focused. +func (m Model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.searching = false + m.query = "" + m.applyFilter() + case tea.KeyBackspace: + if r := []rune(m.query); len(r) > 0 { + m.query = string(r[:len(r)-1]) + m.applyFilter() + } + case tea.KeySpace: + m.query += " " + m.applyFilter() + case tea.KeyRunes: + m.query += string(msg.Runes) + m.applyFilter() + } + return m, nil +} + +// cycleTab moves the active tab by delta and resets the cursor. +func (m *Model) cycleTab(delta int) { + m.tab = tab((int(m.tab) + delta + 3) % 3) + m.cursor = 0 + m.scrollOffset = 0 + m.applyFilter() +} + +// applyFilter recomputes the visible rows from the active tab and query, then +// clamps the cursor and scroll. +func (m *Model) applyFilter() { + m.filtered = m.filtered[:0] + for _, r := range m.rows { + if matchesTab(r, m.tab) && matchesQuery(r, m.query) { + m.filtered = append(m.filtered, r) + } + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.ensureVisible() +} + +func matchesTab(r StackRow, t tab) bool { + switch t { + case tabLocal: + return r.Type == TypeLocal + case tabRemote: + return r.Type == TypeRemote + default: + return true + } +} + +func matchesQuery(r StackRow, q string) bool { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + return true + } + // Search the stack number, base, type, and every branch name — including + // mid-stack branches that are not shown in the Branches column. + parts := make([]string, 0, len(r.Branches)+5) + parts = append(parts, r.NumberDisplay(), r.Base, r.Type.String(), r.BottomBranch, r.TopBranch) + parts = append(parts, r.Branches...) + hay := strings.ToLower(strings.Join(parts, " ")) + return strings.Contains(hay, q) +} + +// current returns the row under the cursor, if any. +func (m Model) current() (StackRow, bool) { + if m.cursor >= 0 && m.cursor < len(m.filtered) { + return m.filtered[m.cursor], true + } + return StackRow{}, false +} + +// moveCursor moves the selection by delta within the filtered rows. +func (m *Model) moveCursor(delta int) { + if len(m.filtered) == 0 { + return + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + m.ensureVisible() +} + +// maxVisibleRows caps how many stack rows the inline picker shows at once. The +// rest are reached by scrolling, so the picker never takes over the screen. +const maxVisibleRows = 10 + +// bodyHeight returns the number of table rows shown at once: the row count +// capped at maxVisibleRows, and further shrunk to fit a short terminal. It never +// returns less than 1 (a line is reserved for the empty-state message). +func (m Model) bodyHeight() int { + rows := len(m.filtered) + if rows < 1 { + rows = 1 + } + limit := maxVisibleRows + if m.height > 0 { + if avail := m.height - m.chromeHeight(); avail < limit { + limit = avail + } + } + if limit < 1 { + limit = 1 + } + if rows > limit { + rows = limit + } + return rows +} + +// chromeHeight is the number of lines reserved around the table body when +// fitting the picker to a short terminal: title, tabs, blank, header, footer +// (5), plus one line of breathing room so the inline picker never exactly fills +// the terminal (which would make it scroll). The search line adds one more. +func (m Model) chromeHeight() int { + chrome := 6 + if m.searching { + chrome++ + } + return chrome +} + +// ensureVisible adjusts the scroll offset so the cursor stays on screen. +func (m *Model) ensureVisible() { + m.scrollOffset = shared.EnsureVisible(m.cursor, m.cursor+1, m.scrollOffset, m.bodyHeight()) +} + +// --- layout --- + +type layout struct { + num, summary, base, status, typ, created int +} + +const colSep = " " + +// computeLayout derives column widths from all rows (stable across filtering) +// and the terminal width. +func (m Model) computeLayout() layout { + l := layout{ + num: lipgloss.Width("#"), + base: lipgloss.Width("Base"), + status: lipgloss.Width("Status"), + typ: lipgloss.Width("Remote"), + created: lipgloss.Width("Created"), + } + for i := range m.rows { + r := &m.rows[i] + l.num = maxInt(l.num, lipgloss.Width(r.NumberDisplay())) + l.base = maxInt(l.base, lipgloss.Width(r.Base)) + l.status = maxInt(l.status, statusWidth(r.Status)) + l.created = maxInt(l.created, lipgloss.Width(r.CreatedDisplay())) + } + if l.base > 24 { + l.base = 24 + } + + sep := lipgloss.Width(colSep) + // selector(2) + num + base + status + typ + created + 5 separators between + // the six data columns. + fixed := 2 + l.num + l.base + l.status + l.typ + l.created + sep*5 + l.summary = m.width - fixed + if l.summary < 10 { + l.summary = 10 + } + return l +} + +// statusWidth returns the visible width of a status bar for the given counts. +func statusWidth(c StatusCounts) int { + t := c.Total() + if t == 0 { + return 1 + } + if t > maxStatusBoxes { + return maxStatusBoxes + } + return t +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// --- view --- + +func (m Model) View() string { + if m.width == 0 { + return "" + } + // On exit, render nothing so the inline picker clears itself; the caller + // prints the outcome (the switched-to branch, or nothing on cancel). + if m.hasResult || m.cancelled { + return "" + } + + lay := m.computeLayout() + var b strings.Builder + + b.WriteString(m.renderTitle()) + b.WriteString("\n") + b.WriteString(m.renderTabs()) + b.WriteString("\n\n") + b.WriteString(m.renderHeaderRow(lay)) + b.WriteString("\n") + b.WriteString(m.renderBody(lay)) + b.WriteString("\n") + if m.searching { + b.WriteString(m.renderSearch()) + b.WriteString("\n") + } + b.WriteString(m.renderFooter()) + + // Guarantee no rendered line exceeds the terminal width. Otherwise a line + // wraps, the inline renderer's line count is off, and the bounded/clear + // behavior breaks on narrow terminals. + return clampToWidth(b.String(), m.width) +} + +// clampToWidth truncates every line of s to at most width cells so nothing +// wraps. +func clampToWidth(s string, width int) string { + if width <= 0 { + return s + } + lines := strings.Split(s, "\n") + for i, ln := range lines { + if lipgloss.Width(ln) > width { + lines[i] = truncate(ln, width) + } + } + return strings.Join(lines, "\n") +} + +func (m Model) renderTitle() string { + left := titleStyle.Render("Checkout a stack") + right := m.positionIndicator() + if right == "" { + return left + } + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + return left + } + return left + strings.Repeat(" ", gap) + right +} + +// positionIndicator returns a right-aligned "start–end of total" hint when the +// list is scrolled (more rows than fit at once), or "" when everything fits. +func (m Model) positionIndicator() string { + total := len(m.filtered) + vis := m.bodyHeight() + if total == 0 || total <= vis { + return "" + } + start := m.scrollOffset + 1 + end := m.scrollOffset + vis + if end > total { + end = total + } + return dimStyle.Render(fmt.Sprintf("%d–%d of %d", start, end, total)) +} + +func (m Model) renderTabs() string { + labels := []tab{tabAll, tabLocal, tabRemote} + parts := make([]string, 0, len(labels)) + for _, t := range labels { + if t == m.tab { + parts = append(parts, tabActiveStyle.Render(t.String())) + } else { + parts = append(parts, tabInactiveStyle.Render(t.String())) + } + } + return strings.Join(parts, " ") +} + +func (m Model) renderHeaderRow(lay layout) string { + var b strings.Builder + b.WriteString(" ") // selector column + b.WriteString(headerStyle.Render(padRight("#", lay.num))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Branches", lay.summary))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Base", lay.base))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Status", lay.status))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Type", lay.typ))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Created", lay.created))) + return b.String() +} + +func (m Model) renderBody(lay layout) string { + bodyH := m.bodyHeight() + if len(m.filtered) == 0 { + return m.padBody(emptyStyle.Render(" No stacks match."), bodyH) + } + + var lines []string + end := m.scrollOffset + bodyH + if end > len(m.filtered) { + end = len(m.filtered) + } + for i := m.scrollOffset; i < end; i++ { + lines = append(lines, m.renderRow(m.filtered[i], i == m.cursor, lay)) + } + return m.padBody(strings.Join(lines, "\n"), bodyH) +} + +// padBody pads content with blank lines so the body always occupies bodyH rows, +// keeping the footer anchored. +func (m Model) padBody(content string, bodyH int) string { + lines := strings.Count(content, "\n") + 1 + if content == "" { + lines = 0 + } + if lines >= bodyH { + return content + } + pad := strings.Repeat("\n", bodyH-lines) + if content == "" { + return strings.TrimPrefix(pad, "\n") + } + return content + pad +} + +func (m Model) renderRow(r StackRow, selected bool, lay layout) string { + var b strings.Builder + + // Selector. + selector := " " + if selected { + selector = styleBg(selectorStyle, true).Render("›") + styleBg(lipgloss.NewStyle(), true).Render(" ") + } + b.WriteString(selector) + + // #, Branches, Base, Status, Type, Created. A missing stack number is + // rendered faint so it stays unobtrusive. + numStyle := numberStyle + if r.Number == 0 { + numStyle = dimStyle + } + b.WriteString(renderCell(r.NumberDisplay(), numStyle, lay.num, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderSummaryCell(r, lay.summary, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.Base, baseStyle, lay.base, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderStatusCell(r.Status, lay.status, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.Type.String(), typeStyle(r.Type), lay.typ, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.CreatedDisplay(), createdStyle, lay.created, selected)) + + line := b.String() + // Extend the selected-row background to the right edge. + if selected { + gap := m.width - lipgloss.Width(line) + if gap > 0 { + line += styleBg(lipgloss.NewStyle(), true).Render(strings.Repeat(" ", gap)) + } + } + return line +} + +// renderSummaryCell renders "bottom...top" with a dimmed separator, each part +// carrying the selected-row background so styling stays continuous. When the +// text is too wide it falls back to a single truncated segment. +func renderSummaryCell(r StackRow, width int, selected bool) string { + if lipgloss.Width(r.Summary()) > width { + return renderCell(r.Summary(), summaryStyle, width, selected) + } + var seg string + if r.BottomBranch != "" && r.TopBranch != "" && r.BottomBranch != r.TopBranch { + seg = styleBg(summaryStyle, selected).Render(r.BottomBranch) + + styleBg(dotsStyle, selected).Render(branchSep) + + styleBg(summaryStyle, selected).Render(r.TopBranch) + } else { + seg = styleBg(summaryStyle, selected).Render(r.Summary()) + } + if pad := width - lipgloss.Width(seg); pad > 0 { + seg += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return seg +} + +func (m Model) renderSearch() string { + return searchLabelStyle.Render("/ ") + searchTextStyle.Render(m.query) + dimStyle.Render("▏") +} + +func (m Model) renderFooter() string { + var pairs [][2]string + if m.searching { + pairs = [][2]string{ + {"↑↓", "navigate"}, + {"enter", "select"}, + {"esc", "clear search"}, + } + } else { + pairs = [][2]string{ + {"↑↓", "navigate"}, + {"←→", "tabs"}, + {"/", "search"}, + {"enter", "select"}, + {"esc", "quit"}, + } + } + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, footerKeyStyle.Render(p[0])+" "+footerDescStyle.Render(p[1])) + } + return strings.Join(parts, footerSepStyle.Render(" · ")) +} + +// --- cell rendering helpers --- + +// styleBg adds the selected-row background to a style when selected. +func styleBg(st lipgloss.Style, selected bool) lipgloss.Style { + if selected { + return st.Background(shared.ColorRowShade) + } + return st +} + +// renderCell renders text in st, truncated/padded to width, applying the +// selected-row background to text and padding when selected. +func renderCell(text string, st lipgloss.Style, width int, selected bool) string { + text = truncate(text, width) + rendered := styleBg(st, selected).Render(text) + if pad := width - lipgloss.Width(rendered); pad > 0 { + rendered += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return rendered +} + +// renderStatusCell renders the status bar padded to width, shaded when selected. +func renderStatusCell(c StatusCounts, width int, selected bool) string { + bar := statusBar(c, selected) + if pad := width - lipgloss.Width(bar); pad > 0 { + bar += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return bar +} + +// sepCell renders the inter-column separator, shaded when selected. +func sepCell(selected bool) string { + if selected { + return styleBg(lipgloss.NewStyle(), true).Render(colSep) + } + return colSep +} diff --git a/internal/tui/checkoutview/model_test.go b/internal/tui/checkoutview/model_test.go new file mode 100644 index 00000000..0b6ce2ee --- /dev/null +++ b/internal/tui/checkoutview/model_test.go @@ -0,0 +1,337 @@ +package checkoutview + +import ( + "fmt" + "regexp" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/stack" +) + +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +func runeKey(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +// drive applies a sequence of messages to the model and returns the result. +func drive(m Model, msgs ...tea.Msg) Model { + var tm tea.Model = m + for _, msg := range msgs { + tm, _ = tm.Update(msg) + } + return tm.(Model) +} + +func sampleRows() []StackRow { + now := time.Now() + return []StackRow{ + {Number: 0, Type: TypeLocal, BottomBranch: "wip-a", TopBranch: "wip-b", Base: "main", + Status: StatusCounts{Unpushed: 2}, LocalStack: &stack.Stack{Number: 0}}, + {Number: 55, Type: TypeRemote, BottomBranch: "ref-1", TopBranch: "ref-3", Base: "main", + Status: StatusCounts{Open: 3}, HasCreated: true, Created: now.Add(-time.Hour)}, + {Number: 42, Type: TypeLocal, BottomBranch: "api-1", TopBranch: "api-3", Base: "trunk", + Status: StatusCounts{Merged: 1, Open: 1, Unpushed: 1}, HasCreated: true, Created: now.Add(-72 * time.Hour), + LocalStack: &stack.Stack{Number: 42}}, + } +} + +func sized(m Model) Model { + return drive(m, tea.WindowSizeMsg{Width: 100, Height: 20}) +} + +func TestNew_InitialState(t *testing.T) { + m := New(sampleRows()) + assert.Equal(t, tabAll, m.tab) + assert.Len(t, m.filtered, 3) + assert.Equal(t, 0, m.cursor) + assert.False(t, m.searching) +} + +func TestTabFiltering(t *testing.T) { + m := sized(New(sampleRows())) + + // All -> Local (one Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabLocal, m.tab) + require.Len(t, m.filtered, 2) + for _, r := range m.filtered { + assert.Equal(t, TypeLocal, r.Type) + } + + // Local -> Remote (another Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabRemote, m.tab) + require.Len(t, m.filtered, 1) + assert.Equal(t, 55, m.filtered[0].Number) + + // Remote -> wraps back to All (another Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabAll, m.tab) + assert.Len(t, m.filtered, 3) + + // Left wraps backwards to Remote. + m = drive(m, tea.KeyMsg{Type: tea.KeyLeft}) + assert.Equal(t, tabRemote, m.tab) +} + +func TestTabSwitchResetsCursor(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 2, m.cursor) + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, 0, m.cursor, "switching tabs resets the cursor") +} + +func TestSearchFiltering(t *testing.T) { + m := sized(New(sampleRows())) + + m = drive(m, runeKey("/")) + assert.True(t, m.searching) + + m = drive(m, runeKey("a"), runeKey("p"), runeKey("i")) + assert.Equal(t, "api", m.query) + require.Len(t, m.filtered, 1) + assert.Equal(t, 42, m.filtered[0].Number) + + // Backspace widens the results again. + m = drive(m, tea.KeyMsg{Type: tea.KeyBackspace}) + assert.Equal(t, "ap", m.query) + + // Esc clears the search and restores all rows. + m = drive(m, tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, m.searching) + assert.Equal(t, "", m.query) + assert.Len(t, m.filtered, 3) +} + +func TestSearchMatchesNumberAndType(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("5"), runeKey("5")) + require.Len(t, m.filtered, 1) + assert.Equal(t, 55, m.filtered[0].Number) + + m = drive(m, tea.KeyMsg{Type: tea.KeyEsc}) + m = drive(m, runeKey("/"), runeKey("r"), runeKey("e"), runeKey("m")) + require.Len(t, m.filtered, 1) + assert.Equal(t, TypeRemote, m.filtered[0].Type) +} + +func TestSearchMatchesMidStackBranch(t *testing.T) { + rows := []StackRow{ + {Number: 7, Type: TypeLocal, BottomBranch: "feat/bottom", TopBranch: "feat/top", + Branches: []string{"feat/bottom", "feat/middle-xyz", "feat/top"}, Base: "main"}, + {Number: 8, Type: TypeRemote, BottomBranch: "other/a", TopBranch: "other/b", + Branches: []string{"other/a", "other/b"}, Base: "main"}, + } + m := sized(New(rows)) + m = drive(m, runeKey("/")) + for _, r := range "middle" { + m = drive(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + require.Len(t, m.filtered, 1, "a mid-stack branch name should match") + assert.Equal(t, 7, m.filtered[0].Number) + // The matched mid-stack branch is not shown in the Branches column. + assert.NotContains(t, stripANSI(m.View()), "middle-xyz") +} + +func TestCursorNavigationClamps(t *testing.T) { + m := sized(New(sampleRows())) + // Up at the top stays at 0. + m = drive(m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 0, m.cursor) + // Down past the end clamps to the last row. + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 2, m.cursor) +} + +func TestEnterSelectsLocalRow(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, tea.KeyMsg{Type: tea.KeyEnter}) + + row, ok := m.Result() + require.True(t, ok) + assert.Equal(t, 0, row.Number) + assert.Equal(t, TypeLocal, row.Type) + require.NotNil(t, row.LocalStack) + assert.False(t, m.Cancelled()) +} + +func TestEnterSelectsRemoteRow(t *testing.T) { + m := sized(New(sampleRows())) + // Move to the remote-only stack (#55, index 1). + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}) + + row, ok := m.Result() + require.True(t, ok) + assert.Equal(t, 55, row.Number) + assert.Equal(t, TypeRemote, row.Type) + assert.Nil(t, row.LocalStack) +} + +func TestCancelKeys(t *testing.T) { + for _, msg := range []tea.Msg{ + tea.KeyMsg{Type: tea.KeyEsc}, + runeKey("q"), + tea.KeyMsg{Type: tea.KeyCtrlC}, + } { + m := sized(New(sampleRows())) + m = drive(m, msg) + _, ok := m.Result() + assert.False(t, ok, "no selection after cancel") + assert.True(t, m.Cancelled()) + } +} + +func TestEnterWithNoMatchesDoesNothing(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("z"), runeKey("z"), runeKey("z")) + require.Empty(t, m.filtered) + + m = drive(m, tea.KeyMsg{Type: tea.KeyEnter}) + _, ok := m.Result() + assert.False(t, ok, "Enter selects nothing when the list is empty") + assert.False(t, m.Cancelled()) +} + +func TestQTypesIntoSearchInsteadOfQuitting(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("q")) + assert.True(t, m.searching) + assert.Equal(t, "q", m.query) + assert.False(t, m.Cancelled()) +} + +func TestView_RendersColumnsAndRows(t *testing.T) { + m := sized(New(sampleRows())) + out := stripANSI(m.View()) + + assert.Contains(t, out, "Checkout a stack") + for _, col := range []string{"#", "Branches", "Base", "Status", "Type", "Created"} { + assert.Contains(t, out, col) + } + assert.Contains(t, out, "wip-a...wip-b") + assert.Contains(t, out, "Remote") + assert.Contains(t, out, "1h ago") + assert.Contains(t, out, "/ search") +} + +func TestView_SearchFooterAndPrompt(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("a")) + out := stripANSI(m.View()) + assert.Contains(t, out, "/ a") + assert.Contains(t, out, "clear search") +} + +func TestView_EmptyStateMessage(t *testing.T) { + m := sized(New(nil)) + out := stripANSI(m.View()) + assert.Contains(t, out, "No stacks match.") +} + +func TestView_NarrowTerminalDoesNotPanic(t *testing.T) { + m := New(sampleRows()) + for _, size := range []tea.WindowSizeMsg{ + {Width: 20, Height: 6}, + {Width: 1, Height: 1}, + {Width: 40, Height: 3}, + } { + mm := drive(m, size) + assert.NotPanics(t, func() { + _ = mm.View() + }) + } +} + +func TestView_NoLineExceedsWidth(t *testing.T) { + // Every rendered line must fit within the terminal width so nothing wraps + // (which would break the inline picker's bounded height). + m := New(sampleRows()) + for _, w := range []int{100, 60, 40, 24, 12} { + mm := drive(m, tea.WindowSizeMsg{Width: w, Height: 20}) + for _, ln := range strings.Split(mm.View(), "\n") { + assert.LessOrEqualf(t, lipgloss.Width(ln), w, "line wider than %d: %q", w, stripANSI(ln)) + } + } +} + +func TestSearchTogglingKeepsCursorVisible(t *testing.T) { + // On a short terminal, entering search shrinks the body; the selected row + // must not scroll out of view (Enter would otherwise select a hidden row). + m := drive(New(manyRows(20)), tea.WindowSizeMsg{Width: 90, Height: 11}) + for m.cursor < m.bodyHeight()-1 { + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}) + } + m = drive(m, runeKey("/")) + require.True(t, m.searching) + assert.GreaterOrEqual(t, m.cursor, m.scrollOffset) + assert.Less(t, m.cursor, m.scrollOffset+m.bodyHeight(), "cursor stays visible after entering search") +} + +func TestView_ZeroSizeReturnsEmpty(t *testing.T) { + m := New(sampleRows()) + assert.Equal(t, "", m.View()) +} + +func manyRows(n int) []StackRow { + rows := make([]StackRow, n) + for i := 0; i < n; i++ { + rows[i] = StackRow{ + Number: 1000 + i, Type: TypeRemote, + BottomBranch: fmt.Sprintf("b%d", i), TopBranch: fmt.Sprintf("t%d", i), + Base: "main", Status: StatusCounts{Open: 2}, + } + } + return rows +} + +func TestView_InlineHeightIsBounded(t *testing.T) { + // A long list on a tall terminal must not take over the screen: it shows at + // most maxVisibleRows rows plus a little chrome, and offers a scroll hint. + m := drive(New(manyRows(30)), tea.WindowSizeMsg{Width: 90, Height: 50}) + lines := len(strings.Split(m.View(), "\n")) + assert.LessOrEqual(t, lines, maxVisibleRows+6, "picker must not fill a tall terminal") + assert.GreaterOrEqual(t, lines, maxVisibleRows, "shows up to maxVisibleRows rows") + assert.Contains(t, stripANSI(m.View()), "of 30", "shows a scroll position indicator") +} + +func TestView_ShrinksToShortTerminal(t *testing.T) { + m := drive(New(manyRows(30)), tea.WindowSizeMsg{Width: 90, Height: 12}) + lines := len(strings.Split(m.View(), "\n")) + assert.LessOrEqual(t, lines, 12, "must fit within a short terminal") + assert.Less(t, lines, maxVisibleRows+6) +} + +func TestView_ClearsOnExit(t *testing.T) { + selected := drive(sized(New(sampleRows())), tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, "", selected.View(), "inline picker clears itself after selection") + + cancelled := drive(sized(New(sampleRows())), tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, "", cancelled.View(), "inline picker clears itself after cancel") +} + +func TestView_NoScrollIndicatorWhenAllFit(t *testing.T) { + m := sized(New(sampleRows())) // 3 rows on a 20-line terminal + assert.NotContains(t, stripANSI(m.View()), " of ") +} + +func TestStatusBar_EmptyAndColors(t *testing.T) { + assert.Equal(t, "—", stripANSI(statusBar(StatusCounts{}, false))) + bar := stripANSI(statusBar(StatusCounts{Merged: 1, Open: 2}, false)) + assert.Equal(t, strings.Repeat(statusBox, 3), bar) +} + +func TestStatusBar_CapsLargeStacks(t *testing.T) { + // A huge stack is summarized into at most maxStatusBoxes cells. + bar := stripANSI(statusBar(StatusCounts{Merged: 40, Open: 40, Closed: 20}, false)) + assert.Equal(t, maxStatusBoxes, len([]rune(bar))) +} diff --git a/internal/tui/checkoutview/styles.go b/internal/tui/checkoutview/styles.go new file mode 100644 index 00000000..5cc811a5 --- /dev/null +++ b/internal/tui/checkoutview/styles.go @@ -0,0 +1,187 @@ +package checkoutview + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" +) + +// maxStatusBoxes caps the number of cells in the status bar. The bar is a +// high-level summary, not one cell per PR: larger stacks are downsampled +// proportionally across these cells so a 3-branch and a 100-PR stack both stay +// compact. +const maxStatusBoxes = 5 + +// statusBox is the glyph used for each cell in the status bar. A lower +// three-quarters block is full width (so cells read as one continuous bar, like +// a full block) but only ~75% of the cell height, leaving an empty strip at the +// top of every cell. That keeps a consistent vertical gap between rows across +// terminals — a full block █ has no gap and merges vertically on terminals with +// no line spacing (e.g. Ghostty), while ■ renders rounded on some terminals. +const statusBox = "▆" + +// Muted status-bar colors: each is a blend of the corresponding vivid PR-state +// color toward the faint text gray, so the bar stays legible but understated in +// both light and dark terminals. +var ( + statusMergedColor = lipgloss.AdaptiveColor{Dark: "#957dc1", Light: "#816abf"} // muted purple + statusOpenColor = lipgloss.AdaptiveColor{Dark: "#509661", Light: "#488462"} // muted green + statusClosedColor = lipgloss.AdaptiveColor{Dark: "#b55d5d", Light: "#ab515d"} // muted red +) + +var ( + // Title and tab strip. + titleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + tabActiveStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Background(shared.ColorRowShade).Bold(true).Padding(0, 1) + tabInactiveStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Padding(0, 1) + + // Table chrome. + headerStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Bold(true) + selectorStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + + // Columns. + numberStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + summaryStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + dotsStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + baseStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + localTypeStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + remoteTypeStyle = lipgloss.NewStyle().Foreground(shared.ColorPurple) + createdStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + dimStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + + // Status squares use muted, desaturated variants of the PR-state colors so + // the bar reads as a subtle progress indicator and does not pull focus from + // the leading columns. Unpushed uses the faint text color (least prominent). + statusMergedStyle = lipgloss.NewStyle().Foreground(statusMergedColor) + statusOpenStyle = lipgloss.NewStyle().Foreground(statusOpenColor) + statusClosedStyle = lipgloss.NewStyle().Foreground(statusClosedColor) + statusUnpushedStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + + // Search + footer. + searchLabelStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + searchTextStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + footerKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent) + footerDescStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + footerSepStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) + emptyStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) +) + +// typeStyle returns the style for a stack's Type column. +func typeStyle(t StackType) lipgloss.Style { + if t == TypeRemote { + return remoteTypeStyle + } + return localTypeStyle +} + +// statusBar renders a stack's composition as colored squares: purple (merged), +// green (open), red (closed), gray (unpushed), left to right. It returns "—" +// for an empty stack. Stacks with more than maxStatusBoxes branches are +// downsampled proportionally. When selected, each square carries the +// selected-row background so the shade is continuous. +func statusBar(c StatusCounts, selected bool) string { + if c.Total() == 0 { + return styleBg(dimStyle, selected).Render("—") + } + boxes := distribute(c.boxOrder(), maxStatusBoxes) + styles := []lipgloss.Style{statusMergedStyle, statusOpenStyle, statusClosedStyle, statusUnpushedStyle} + var b strings.Builder + for i, n := range boxes { + if n <= 0 { + continue + } + b.WriteString(styleBg(styles[i], selected).Render(strings.Repeat(statusBox, n))) + } + return b.String() +} + +// boxOrder returns the counts in status-bar render order. +func (c StatusCounts) boxOrder() []int { + return []int{c.Merged, c.Open, c.Closed, c.Unpushed} +} + +// distribute scales counts so they sum to at most max, using the largest- +// remainder method when the total exceeds max. When the total already fits, the +// counts are returned unchanged (one square per branch). +func distribute(counts []int, max int) []int { + total := 0 + for _, n := range counts { + total += n + } + if total <= max { + out := make([]int, len(counts)) + copy(out, counts) + return out + } + + out := make([]int, len(counts)) + rem := make([]float64, len(counts)) + assigned := 0 + for i, n := range counts { + q := float64(n) * float64(max) / float64(total) + out[i] = int(q) + rem[i] = q - float64(out[i]) + assigned += out[i] + } + for assigned < max { + best, bestI := -1.0, -1 + for i := range counts { + if rem[i] > best { + best, bestI = rem[i], i + } + } + if bestI < 0 { + break + } + out[bestI]++ + rem[bestI] = -1 + assigned++ + } + return out +} + +// padRight pads s with spaces to at least width visible columns. +func padRight(s string, width int) string { + w := lipgloss.Width(s) + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} + +// truncate shortens s to at most width visible columns, appending an ellipsis +// when it had to cut. It resets styling at the cut so trailing ANSI does not +// leak. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + var b strings.Builder + w := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + } + if inEscape { + b.WriteRune(r) + if r == 'm' { + inEscape = false + } + continue + } + if w >= width-1 { + b.WriteString("…") + b.WriteString("\x1b[0m") + break + } + b.WriteRune(r) + w++ + } + return b.String() +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 2ed2be8e..3c74c516 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -63,13 +63,14 @@ var keys = keyMap{ // Model is the Bubbletea model for the interactive stack view. type Model struct { - nodes []BranchNode - trunk stack.BranchRef - version string - cursor int // index into nodes (displayed top-down, so 0 = top of stack) - help help.Model - width int - height int + nodes []BranchNode + trunk stack.BranchRef + version string + stackNumber int + cursor int // index into nodes (displayed top-down, so 0 = top of stack) + help help.Model + width int + height int // scrollOffset tracks vertical scroll position for tall stacks. scrollOffset int @@ -78,8 +79,9 @@ type Model struct { checkoutBranch string } -// New creates a new stack view model. -func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { +// New creates a new stack view model. stackNumber is the human-facing stack +// number shown in the header; pass 0 when it is not known. +func New(nodes []BranchNode, trunk stack.BranchRef, version string, stackNumber int) Model { h := help.New() h.ShowAll = true @@ -103,11 +105,12 @@ func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { } return Model{ - nodes: nodes, - trunk: trunk, - version: version, - cursor: cursor, - help: h, + nodes: nodes, + trunk: trunk, + version: version, + stackNumber: stackNumber, + cursor: cursor, + help: h, } } @@ -443,15 +446,22 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { // is hidden and the actions that depend on it are dimmed; only quit works. allMerged := branchCount > 0 && mergedCount == branchCount + infoLines := make([]shared.HeaderInfoLine, 0, 3) + if m.stackNumber > 0 { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d", m.stackNumber)}) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "✓", Label: "Stack initialized"}) + } + infoLines = append(infoLines, + shared.HeaderInfoLine{Icon: "◼", Label: "Base: " + m.trunk.Branch}, + shared.HeaderInfoLine{Icon: branchIcon, Label: branchInfo}, + ) + return shared.HeaderConfig{ - ShowArt: true, - Title: "View Stack", - Subtitle: "v" + m.version, - InfoLines: []shared.HeaderInfoLine{ - {Icon: "✓", Label: "Stack initialized"}, - {Icon: "◆", Label: "Base: " + m.trunk.Branch}, - {Icon: branchIcon, Label: branchInfo}, - }, + ShowArt: true, + Title: "View Stack", + Subtitle: "v" + m.version, + InfoLines: infoLines, ShortcutColumns: 1, Shortcuts: []shared.ShortcutEntry{ {Key: "↑↓", Desc: "navigate", Disabled: allMerged}, diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go index ea4111f6..ab81d878 100644 --- a/internal/tui/stackview/model_test.go +++ b/internal/tui/stackview/model_test.go @@ -46,7 +46,7 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].IsCurrent = true - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor) } @@ -54,14 +54,14 @@ func TestNew_CursorAtCurrentBranch(t *testing.T) { func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) } func TestUpdate_KeyboardNavigation(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) // Down @@ -98,7 +98,7 @@ func TestUpdate_KeyboardNavigation(t *testing.T) { func TestUpdate_ToggleCommits(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].Commits = []git.CommitInfo{{SHA: "abc", Subject: "test"}} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.False(t, m.nodes[0].CommitsExpanded) @@ -114,7 +114,7 @@ func TestUpdate_ToggleCommits(t *testing.T) { func TestUpdate_ToggleFiles(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.False(t, m.nodes[0].FilesExpanded) @@ -130,7 +130,7 @@ func TestUpdate_ToggleFiles(t *testing.T) { func TestUpdate_Quit(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) quitKeys := []string{"q", "esc", "ctrl+c"} for _, k := range quitKeys { @@ -145,7 +145,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true nodes[1].PR = &ghapi.PRDetails{Number: 42, URL: "https://github.com/pr/42"} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Move to b2 (non-current) updated, _ := m.Update(keyMsg("down")) @@ -163,7 +163,7 @@ func TestUpdate_CheckoutOnEnter(t *testing.T) { func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { nodes := makeNodes("b1", "b2") nodes[0].IsCurrent = true - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor) // Press enter on current node @@ -176,7 +176,7 @@ func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { func TestView_HeaderShownWhenTallEnough(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Simulate a tall and wide terminal updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) @@ -195,7 +195,7 @@ func TestView_HeaderShownWhenTallEnough(t *testing.T) { func TestView_HeaderHiddenWhenShort(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Simulate a short terminal (below minHeightForHeader) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) @@ -211,7 +211,7 @@ func TestView_HeaderHiddenWhenShort(t *testing.T) { func TestView_HeaderHiddenWhenNarrow(t *testing.T) { nodes := makeNodes("b1") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Tall but too narrow for header (below minWidthForHeader) updated, _ := m.Update(tea.WindowSizeMsg{Width: 35, Height: 40}) @@ -224,7 +224,7 @@ func TestView_HeaderHiddenWhenNarrow(t *testing.T) { func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Even at medium width, shortcuts should still be visible updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) @@ -238,7 +238,7 @@ func TestView_HeaderShortcutsAlwaysVisible(t *testing.T) { func TestView_HeaderShowsMergedCount(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -251,7 +251,7 @@ func TestView_HeaderShowsQueuedCount(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].Ref.Queued = true nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 10} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -263,7 +263,7 @@ func TestView_HeaderShowsQueuedCount(t *testing.T) { func TestView_QueuedPRShowsQueuedLabel(t *testing.T) { nodes := makeNodes("b1") nodes[0].PR = &ghapi.PRDetails{Number: 99, IsQueued: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) m = updated.(Model) @@ -294,7 +294,7 @@ func TestView_BranchProgressIcon(t *testing.T) { for _, idx := range tt.merged { nodes[idx].Ref.PullRequest = &stack.PullRequestRef{Merged: true} } - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -306,7 +306,7 @@ func TestView_BranchProgressIcon(t *testing.T) { func TestMouseClick_HeaderAreaIgnored(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) @@ -323,7 +323,7 @@ func TestMouseClick_HeaderAreaIgnored(t *testing.T) { func TestScrollClamp_CannotScrollPastContent(t *testing.T) { nodes := makeNodes("b1", "b2") - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Tall terminal with plenty of room for content updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -346,7 +346,7 @@ func TestScrollClamp_CannotScrollPastContent(t *testing.T) { func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[1].Ref.PullRequest = &stack.PullRequestRef{Number: 2, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 0, m.cursor, "cursor should start on first non-merged branch") // Down should skip b2 (merged) and land on b3 @@ -363,7 +363,7 @@ func TestUpdate_CursorSkipsMergedBranches(t *testing.T) { func TestNew_CursorSkipsMergedBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor, "cursor should skip merged b1 and start on b2") } @@ -371,7 +371,7 @@ func TestNew_CursorSkipsMergedCurrentBranch(t *testing.T) { nodes := makeNodes("b1", "b2", "b3") nodes[0].IsCurrent = true nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) assert.Equal(t, 1, m.cursor, "cursor should not start on merged current branch") } @@ -380,7 +380,7 @@ func TestUpdate_EnterOnMergedDoesNothing(t *testing.T) { // by having b1 active and b2 merged and b3 active. nodes := makeNodes("b1", "b2") nodes[0].Ref.PullRequest = &stack.PullRequestRef{Number: 1, Merged: true} - m := New(nodes, testTrunk, "0.0.1") + m := New(nodes, testTrunk, "0.0.1", 0) // Cursor is on b2 (first non-merged). Manually set to b1 to test guard. m.cursor = 0 @@ -400,12 +400,12 @@ func makeAllMergedNodes(branches ...string) []BranchNode { } func TestNew_CursorHiddenWhenAllMerged(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) assert.Equal(t, -1, m.cursor, "cursor should be hidden when every branch is merged") } func TestUpdate_AllMergedCursorStaysHidden(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2", "b3"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2", "b3"), testTrunk, "0.0.1", 0) updated, _ := m.Update(keyMsg("down")) m = updated.(Model) @@ -422,7 +422,7 @@ func TestUpdate_AllMergedCursorStaysHidden(t *testing.T) { } func TestView_AllMergedRendersWithoutPanic(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) m = updated.(Model) // Should not panic with a hidden (-1) cursor. @@ -431,7 +431,7 @@ func TestView_AllMergedRendersWithoutPanic(t *testing.T) { } func TestBuildHeaderConfig_DisablesShortcutsWhenAllMerged(t *testing.T) { - m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeAllMergedNodes("b1", "b2"), testTrunk, "0.0.1", 0) cfg := m.buildHeaderConfig() require.NotEmpty(t, cfg.Shortcuts) @@ -445,7 +445,7 @@ func TestBuildHeaderConfig_DisablesShortcutsWhenAllMerged(t *testing.T) { } func TestBuildHeaderConfig_ShortcutsEnabledWithActiveBranches(t *testing.T) { - m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1") + m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 0) cfg := m.buildHeaderConfig() require.NotEmpty(t, cfg.Shortcuts) @@ -453,3 +453,22 @@ func TestBuildHeaderConfig_ShortcutsEnabledWithActiveBranches(t *testing.T) { assert.False(t, sc.Disabled, "%q should be enabled when there are active branches", sc.Desc) } } + +func TestBuildHeaderConfig_ShowsStackNumber(t *testing.T) { + m := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 7) + cfg := m.buildHeaderConfig() + + found := false + for _, line := range cfg.InfoLines { + if line.Label == "Stack #7" { + found = true + } + } + assert.True(t, found, "header should show the stack number when known") + + // When the number is unknown (0), no stack-number line is shown. + m0 := New(makeNodes("b1", "b2"), testTrunk, "0.0.1", 0) + for _, line := range m0.buildHeaderConfig().InfoLines { + assert.NotContains(t, line.Label, "Stack #") + } +} diff --git a/internal/tui/submitview/data.go b/internal/tui/submitview/data.go index 8db39f1c..05303b7c 100644 --- a/internal/tui/submitview/data.go +++ b/internal/tui/submitview/data.go @@ -123,6 +123,20 @@ func CountSelected(nodes []SubmitNode) int { return n } +// CountOpenPRs returns the number of branches that already have an open pull +// request (open or draft). These are the PRs that can be linked into a stack via +// the "STACK N PRs" action. Queued, merged, and closed PRs are excluded because +// they cannot form a new stack. +func CountOpenPRs(nodes []SubmitNode) int { + n := 0 + for _, node := range nodes { + if node.State == StateOpen || node.State == StateDraft { + n++ + } + } + return n +} + // HasClosed reports whether any branch in the list has a closed PR, which blocks // the stack and triggers the closed-branch callout. func HasClosed(nodes []SubmitNode) bool { diff --git a/internal/tui/submitview/help.go b/internal/tui/submitview/help.go index ef85f0d4..af0d609c 100644 --- a/internal/tui/submitview/help.go +++ b/internal/tui/submitview/help.go @@ -53,6 +53,7 @@ var helpSections = []helpSection{ heading: "Existing PRs", entries: []helpEntry{ {"^o", "open the focused branch's PR on the web"}, + {"^b", "link the existing open PRs into a stack (when there's no stack on GitHub)"}, }, }, { diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go index 7c6c679d..b1264fc7 100644 --- a/internal/tui/submitview/model.go +++ b/internal/tui/submitview/model.go @@ -45,6 +45,14 @@ type Options struct { RepoLabel string // Version is the CLI version string. Version string + // CanCreateStack reports that the local stack has no remote stack object yet + // but one could be created (stacked PRs are available on the repo). When + // true, and once the user has deselected all new PRs, the TUI offers a + // "STACK N PRs" action to link the existing open PRs into a stack. + CanCreateStack bool + // StackNumber is the human-facing stack number shown in the header. Zero + // when the local stack has not yet been created on GitHub. + StackNumber int } // Model is the Bubble Tea model backing the interactive `gh stack submit` TUI. @@ -54,6 +62,14 @@ type Model struct { repoLabel string version string + // canCreateStack mirrors Options.CanCreateStack: the local stack has no + // remote stack object yet, but one could be created. + canCreateStack bool + + // stackNumber is the human-facing stack number shown in the header (0 when + // the stack has not been created on GitHub yet). + stackNumber int + cursor int // index into nodes (the focused branch) width, height int @@ -139,6 +155,9 @@ func New(opts Options) Model { version: opts.Version, cursor: cursor, + canCreateStack: opts.CanCreateStack, + stackNumber: opts.StackNumber, + titleArea: tia, descArea: ta, focusedField: fieldTitle, @@ -263,6 +282,15 @@ func (m Model) anyEdited() bool { return false } +// canStackExistingPRs reports whether the "STACK N PRs" action should be offered: +// the local stack has no remote stack object yet, the user has deselected every +// new PR, and there are at least two existing open PRs to link into a stack. +func (m Model) canStackExistingPRs() bool { + return m.canCreateStack && + CountSelected(m.nodes) == 0 && + CountOpenPRs(m.nodes) >= 2 +} + // quit marks the session cancelled and exits. If the user has unsaved edits, it // first raises a discard-edits confirmation instead of quitting immediately. func (m Model) quit() (tea.Model, tea.Cmd) { diff --git a/internal/tui/submitview/model_test.go b/internal/tui/submitview/model_test.go index d411892c..11c86309 100644 --- a/internal/tui/submitview/model_test.go +++ b/internal/tui/submitview/model_test.go @@ -292,9 +292,9 @@ func TestHeaderConfig_InfoLines(t *testing.T) { // Repo and base are the first two info lines, in order. cfg := testModel(t, newNodes()).buildHeaderConfig() require.GreaterOrEqual(t, len(cfg.InfoLines), 3) - assert.Equal(t, "○", cfg.InfoLines[0].Icon) + assert.Equal(t, "◆", cfg.InfoLines[0].Icon) assert.Equal(t, "Repo: myorg/myrepo", cfg.InfoLines[0].Label) - assert.Equal(t, "◆", cfg.InfoLines[1].Icon) + assert.Equal(t, "○", cfg.InfoLines[1].Icon) assert.Equal(t, "Base: main", cfg.InfoLines[1].Label) // Two included NEW branches -> solid (styled) square, pluralized. @@ -320,6 +320,45 @@ func TestHeaderConfig_InfoLines(t *testing.T) { assert.Nil(t, noneLast.IconStyle, "the empty line uses the default icon style") } +func TestHeaderConfig_StackNumberInRepoLine(t *testing.T) { + // When the stack has a number, it is folded into the first info line + // alongside the repo; otherwise the line shows just the repo. + m := New(Options{ + Nodes: newNodes(), + Trunk: stack.BranchRef{Branch: "main"}, + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + StackNumber: 7, + }) + assert.Equal(t, "Stack #7 • myorg/myrepo", m.buildHeaderConfig().InfoLines[0].Label) + + // Without a stack number, the first line is just the repo. + assert.Equal(t, "Repo: myorg/myrepo", testModel(t, newNodes()).buildHeaderConfig().InfoLines[0].Label) +} + +func TestLeftPanel_HeaderShowsStackNumber(t *testing.T) { + // The left-panel "STACK" header includes the stack number when known. + withNum := New(Options{ + Nodes: newNodes(), + Trunk: stack.BranchRef{Branch: "main"}, + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + StackNumber: 42, + }) + sized, _ := withNum.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + withNum = sized.(Model) + leftW, _ := withNum.panelWidths() + header := withNum.buildLeftRows(leftW - 2)[0].text + assert.Contains(t, header, "STACK #42") + + // Without a stack number, the header is the bare "STACK" label. + noNum := testModel(t, newNodes()) + leftW2, _ := noNum.panelWidths() + header2 := noNum.buildLeftRows(leftW2 - 2)[0].text + assert.Contains(t, header2, "STACK") + assert.NotContains(t, header2, "STACK #") +} + func TestView_ClosedBanner(t *testing.T) { nodes := newNodes() nodes = append(nodes, newNode("feat/auth/legacy", StateClosed)) diff --git a/internal/tui/submitview/mouse.go b/internal/tui/submitview/mouse.go index 7eb21c71..924a762d 100644 --- a/internal/tui/submitview/mouse.go +++ b/internal/tui/submitview/mouse.go @@ -137,6 +137,25 @@ func (m Model) leftCheckboxHit(x, y int) bool { return x >= leftW-5 && x < leftW-2 } +// leftStackButtonHit reports whether (x,y) lands on the bottom-left "STACK N PRs" +// button, when it is shown. The button occupies the last reserved row of the +// left panel (one blank separator row sits above it). +func (m Model) leftStackButtonHit(x, y int) bool { + if !m.canStackExistingPRs() { + return false + } + leftW, _ := m.panelWidths() + if x < 0 || x >= leftW { + return false + } + if y-m.panelTopRow() != m.leftVisibleHeight()+1 { + return false + } + // The button is rendered inside the panel's one-cell left border, so the + // hit target starts at screen column 1, not 0. + return x >= 1 && x < 1+lipgloss.Width(m.renderStackButton(leftW-2)) +} + // handleClick routes a left click to a branch row (left map) or an editor // element (right panel). func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) { @@ -145,6 +164,12 @@ func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) { leftW, rightW := m.panelWidths() + // Bottom-left STACK button: link the existing open PRs into a stack. + if m.leftStackButtonHit(x, y) { + m.saveEditor() + return m.requestSubmit() + } + // Left panel: focus a branch, toggling include when its checkbox is clicked. if idx := m.branchRowAt(x, y); idx != -1 { onCheckbox := m.leftCheckboxHit(x, y) && m.nodes[idx].State == StateNew diff --git a/internal/tui/submitview/render.go b/internal/tui/submitview/render.go index ca925851..a3107e2d 100644 --- a/internal/tui/submitview/render.go +++ b/internal/tui/submitview/render.go @@ -42,10 +42,13 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { repo = "unknown" } - infoLines := []shared.HeaderInfoLine{ - {Icon: "○", Label: "Repo: " + repo}, - {Icon: "◆", Label: "Base: " + m.trunk.Branch}, + infoLines := make([]shared.HeaderInfoLine, 0, 3) + if m.stackNumber > 0 { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d • %s", m.stackNumber, repo)}) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: "Repo: " + repo}) } + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "○", Label: "Base: " + m.trunk.Branch}) // Third line mirrors the modify header's pending line: a solid yellow square // with the count when PRs will be created, or an empty square otherwise. @@ -81,13 +84,19 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { } // headerShortcuts returns the six primary single-screen keyboard shortcuts shown -// in the header (the help overlay lists the full set). +// in the header (the help overlay lists the full set). When the STACK action is +// offered (no stack yet and every new PR deselected), the submit hint is swapped +// for the "^b stack PRs" action, which is the relevant control in that state. func (m Model) headerShortcuts() []shared.ShortcutEntry { + primary := shared.ShortcutEntry{Key: "^s", Desc: "submit PRs"} + if m.canStackExistingPRs() { + primary = shared.ShortcutEntry{Key: "^b", Desc: "stack PRs"} + } return []shared.ShortcutEntry{ {Key: "↑↓", Desc: "select branch"}, {Key: "tab", Desc: "cycle field"}, {Key: "^x", Desc: "skip/include"}, - {Key: "^s", Desc: "submit PRs"}, + primary, {Key: "^h", Desc: "help"}, {Key: "esc", Desc: "quit"}, } diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go index f323e377..6f2e88da 100644 --- a/internal/tui/submitview/screen.go +++ b/internal/tui/submitview/screen.go @@ -13,6 +13,14 @@ import ( // updateScreen handles all key input on the single submit screen. func (m Model) updateScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Ctrl+B links the existing open PRs into a stack. It is only intercepted + // while the STACK action is offered; otherwise it falls through so the + // editor's textarea keeps Ctrl+B (move cursor back) while editing. + if msg.Type == tea.KeyCtrlB && m.canStackExistingPRs() { + m.saveEditor() + return m.requestSubmit() + } + // Global keys, handled regardless of focus. switch msg.Type { case tea.KeyCtrlC: @@ -688,7 +696,8 @@ func (m Model) renderLeftPanel(width, height int) string { fullW = 6 } rows := m.buildLeftRows(fullW) - visH := height - 2 + buttonRows := m.leftButtonRows() + visH := height - 2 - buttonRows if visH < 1 { visH = 1 } @@ -698,14 +707,36 @@ func (m Model) renderLeftPanel(width, height int) string { end = len(rows) } - var b strings.Builder - for i, r := range rows[scroll:end] { - if i > 0 { - b.WriteString("\n") - } - b.WriteString(r.text) + lines := make([]string, 0, visH+buttonRows) + for _, r := range rows[scroll:end] { + lines = append(lines, r.text) + } + // Pad the scroll area to its full height so the button sits at the bottom. + for len(lines) < visH { + lines = append(lines, "") + } + if buttonRows > 0 { + lines = append(lines, "", m.renderStackButton(fullW)) } - return leftPanelBox(b.String(), width, height) + return leftPanelBox(strings.Join(lines, "\n"), width, height) +} + +// leftButtonRows returns the number of inner rows reserved at the bottom of the +// left panel for the "STACK N PRs" button (a blank separator plus the button), +// or 0 when the button is not shown. +func (m Model) leftButtonRows() int { + if m.canStackExistingPRs() { + return 2 + } + return 0 +} + +// renderStackButton renders the bottom-left "(^b) STACK N PRs" action, styled +// like the right-panel SUBMIT button. The keyboard hint sits to the left. +func (m Model) renderStackButton(fullW int) string { + n := CountOpenPRs(m.nodes) + label := hintStyle.Render("(^b) ") + submitButtonStyle.Render(fmt.Sprintf("STACK %d PRs", n)) + return pad(1, false) + label } // leftPanelBox frames the left panel with the shared rounded border but no inner @@ -734,8 +765,12 @@ func leftPanelBox(content string, width, height int) string { // nodes/cursor/width, so the mouse layer can recompute it to resolve clicks. func (m Model) buildLeftRows(fullW int) []leftRow { cur := m.cursor + stackLabel := "STACK" + if m.stackNumber > 0 { + stackLabel = fmt.Sprintf("STACK #%d", m.stackNumber) + } rows := []leftRow{ - {text: pad(1, false) + sectionLabelStyle.Render("STACK"), branch: -1}, + {text: pad(1, false) + sectionLabelStyle.Render(stackLabel), branch: -1}, {text: m.gapRow(fullW, false, cur == 0), branch: -1}, // blank under STACK; top pad for branch 0 } for i := range m.nodes { @@ -892,7 +927,7 @@ func pad(n int, focused bool) string { // leftVisibleHeight is the number of timeline rows the left panel can show. func (m Model) leftVisibleHeight() int { - h := m.contentHeight() - 2 // panel border + h := m.contentHeight() - 2 - m.leftButtonRows() // panel border + reserved button rows if h < 1 { h = 1 } diff --git a/internal/tui/submitview/stackbutton_test.go b/internal/tui/submitview/stackbutton_test.go new file mode 100644 index 00000000..cf7fe548 --- /dev/null +++ b/internal/tui/submitview/stackbutton_test.go @@ -0,0 +1,112 @@ +package submitview + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +// deselectedNew builds a NEW branch node that the user has deselected. +func deselectedNew(branch string) SubmitNode { + n := newNode(branch, StateNew) + n.Included = false + return n +} + +// stackModel builds a sized model with the given CanCreateStack flag. +func stackModel(t *testing.T, nodes []SubmitNode, canCreateStack bool) Model { + t.Helper() + m := New(Options{ + Nodes: nodes, + Trunk: stack.BranchRef{Branch: "main"}, + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + CanCreateStack: canCreateStack, + }) + m.openURL = func(string) {} + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + return updated.(Model) +} + +func TestCountOpenPRs(t *testing.T) { + nodes := []SubmitNode{ + newNode("a", StateNew), + newNode("b", StateOpen), + newNode("c", StateDraft), + newNode("d", StateQueued), + newNode("e", StateMerged), + newNode("f", StateClosed), + } + assert.Equal(t, 2, CountOpenPRs(nodes), "only open and draft PRs count") +} + +func TestCanStackExistingPRs(t *testing.T) { + twoOpen := []SubmitNode{newNode("a", StateOpen), newNode("b", StateDraft)} + tests := []struct { + name string + nodes []SubmitNode + canCreateStack bool + want bool + }{ + {"two open PRs, no stack, none selected", twoOpen, true, true}, + {"deselected NEW + two open PRs", []SubmitNode{deselectedNew("n"), newNode("a", StateOpen), newNode("b", StateDraft)}, true, true}, + {"has a remote stack already", twoOpen, false, false}, + {"a NEW branch is still selected", []SubmitNode{newNode("n", StateNew), newNode("a", StateOpen), newNode("b", StateDraft)}, true, false}, + {"only one open PR", []SubmitNode{newNode("a", StateOpen)}, true, false}, + {"open PRs but queued/merged do not count", []SubmitNode{newNode("a", StateOpen), newNode("b", StateQueued), newNode("c", StateMerged)}, true, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := stackModel(t, tt.nodes, tt.canCreateStack) + assert.Equal(t, tt.want, m.canStackExistingPRs()) + }) + } +} + +func TestStackButton_RendersWhenEligible(t *testing.T) { + m := stackModel(t, []SubmitNode{deselectedNew("feat/new"), newNode("a", StateOpen), newNode("b", StateDraft)}, true) + view := m.View() + assert.Contains(t, view, "STACK 2 PRs") + assert.Contains(t, view, "(^b)") +} + +func TestStackButton_HiddenWhenNotEligible(t *testing.T) { + // Has a remote stack already. + withStack := stackModel(t, []SubmitNode{newNode("a", StateOpen), newNode("b", StateDraft)}, false) + assert.NotContains(t, withStack.View(), "STACK 2 PRs") + assert.NotContains(t, withStack.View(), "(^b)") + + // A NEW branch is still selected for creation. + newSelected := stackModel(t, []SubmitNode{newNode("n", StateNew), newNode("a", StateOpen), newNode("b", StateDraft)}, true) + assert.NotContains(t, newSelected.View(), "STACK 2 PRs") + assert.NotContains(t, newSelected.View(), "(^b)") +} + +func TestStackButton_HeaderHintSwaps(t *testing.T) { + eligible := stackModel(t, []SubmitNode{deselectedNew("feat/new"), newNode("a", StateOpen), newNode("b", StateDraft)}, true) + view := eligible.View() + assert.Contains(t, view, "stack PRs", "header shows the stack hint when eligible") + assert.NotContains(t, view, "submit PRs", "the submit hint is swapped out in stack mode") +} + +func TestCtrlB_TriggersSubmitWhenEligible(t *testing.T) { + m := stackModel(t, []SubmitNode{deselectedNew("feat/new"), newNode("a", StateOpen), newNode("b", StateDraft)}, true) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlB}) + assert.True(t, m.SubmitRequested(), "Ctrl+B requests submit when the STACK action is offered") +} + +func TestCtrlB_NoOpWhenNotEligible(t *testing.T) { + // A NEW branch is included (editing context) — Ctrl+B must not submit. + m := stackModel(t, []SubmitNode{newNode("n", StateNew), newNode("a", StateOpen)}, true) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlB}) + assert.False(t, m.SubmitRequested(), "Ctrl+B does not submit when the STACK action is not offered") +} + +func TestStackButton_MouseClickTriggersSubmit(t *testing.T) { + m := stackModel(t, []SubmitNode{deselectedNew("feat/new"), newNode("a", StateOpen), newNode("b", StateDraft)}, true) + y := m.panelTopRow() + m.leftVisibleHeight() + 1 // the button's reserved bottom row + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 2, Y: y}) + assert.True(t, updated.(Model).SubmitRequested(), "clicking the STACK button requests submit") +} diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md index ca528eac..31554a6f 100644 --- a/skills/gh-stack/SKILL.md +++ b/skills/gh-stack/SKILL.md @@ -7,7 +7,7 @@ description: > branch chains, or incremental code review workflows. metadata: author: github - version: "0.0.7" + version: "0.0.8" --- # gh-stack @@ -16,9 +16,9 @@ metadata: ``` main (trunk) - └── feat/auth-layer → PR #1 (base: main) - bottom (closest to trunk) - └── feat/api-endpoints → PR #2 (base: feat/auth-layer) - └── feat/frontend → PR #3 (base: feat/api-endpoints) - top (furthest from trunk) + └── auth-layer → PR #1 (base: main) - bottom (closest to trunk) + └── api-endpoints → PR #2 (base: auth-layer) + └── frontend → PR #3 (base: api-endpoints) - top (furthest from trunk) ``` The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from the trunk. Each branch inherits from the one below it. Navigation commands (`up`, `down`, `top`, `bottom`) follow this model: `up` moves away from trunk, `down` moves toward it. @@ -52,16 +52,15 @@ git config remote.pushDefault origin # if multiple remotes exist (skips remo **All `gh stack` commands must be run non-interactively.** Every command invocation must include the flags and positional arguments needed to avoid prompts, TUIs, and interactive menus. If a command would prompt for input, it will hang indefinitely. -1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. Running these commands without arguments triggers interactive prompts. -2. **When a prefix is set, pass only the suffix to `add`.** `gh stack add auth` with prefix `feat` → `feat/auth`. Passing `feat/auth` creates `feat/feat/auth`. -3. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR. -4. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. -5. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, `link`, and `checkout` trigger an interactive remote picker. -6. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. -7. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. -8. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes. -9. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue. -10. **Use `gh stack link` for external tool workflows.** When branches are managed by an external tool (jj, Sapling, etc.), use `gh stack link branch-a branch-b`. `link` does not rely on local tracking state and is intended for API-driven PR and stack management. Always provide at least 2 branch names or PR numbers. +1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. Running these commands without arguments triggers interactive prompts. Branch names are used exactly as given — a name is never prefixed or transformed, so `gh stack add refactor/foo` creates a branch named `refactor/foo`. +2. **Always use `--auto` with `gh stack submit`** to auto-generate PR titles. Without `--auto`, `submit` prompts for a title for each new PR. +3. **Always use `--json` with `gh stack view`.** Without `--json`, the command launches an interactive TUI that cannot be operated by agents. There is no other appropriate flag — always pass `--json`. +4. **Use `--remote ` when multiple remotes are configured**, or pre-configure `git config remote.pushDefault origin`. Without this, `push`, `submit`, `sync`, `link`, and `checkout` trigger an interactive remote picker. +5. **Avoid branches shared across multiple stacks.** If a branch belongs to multiple stacks, commands exit with code 6. Check out a non-shared branch first. +6. **Plan your stack layers by dependency order before writing code.** Foundational changes (models, APIs, shared utilities) go in lower branches; dependent changes (UI, consumers) go in higher branches. Think through the dependency chain before running `gh stack init`. +7. **Use standard `git add` and `git commit` for staging and committing.** This gives you full control over which changes go into each branch. The `-Am` shortcut is available but should not be the default approach—stacked PRs are most effective when each branch contains a deliberate, logical set of changes. +8. **Navigate down the stack when you need to change a lower layer.** If you're working on a frontend branch and realize you need API changes, don't hack around it at the current layer. Navigate to the appropriate branch (`gh stack down`, `gh stack checkout`, or `gh stack bottom`), make and commit the changes there, run `gh stack rebase --upstack`, then navigate back up to continue. +9. **Use `gh stack link` for external tool workflows.** When branches are managed by an external tool (jj, Sapling, etc.), use `gh stack link branch-a branch-b`. `link` does not rely on local tracking state and is intended for API-driven PR and stack management. Provide at least two branches/PRs to create or update a stack, or a stack number followed by the new branches/PRs to append them to the top of an existing stack (e.g. `gh stack link 7 branch-c`). **Never do any of the following — each triggers an interactive prompt or TUI that will hang:** - ❌ `gh stack view` or `gh stack view --short` — always use `gh stack view --json` @@ -83,24 +82,24 @@ Stacked branches form a dependency chain: each branch builds on the one below it ``` main (trunk) - └── feat/data-models ← shared types, database schema - └── feat/api-endpoints ← API routes that use the models - └── feat/frontend-ui ← UI components that call the APIs - └── feat/integration ← tests that exercise the full stack + └── data-models ← shared types, database schema + └── api-endpoints ← API routes that use the models + └── frontend-ui ← UI components that call the APIs + └── integration ← tests that exercise the full stack ``` This is illustrative — choose branch names and layer boundaries that reflect the specific work you're doing. The key principle is: if code in one layer depends on code in another, the dependency must be in the same branch or a lower one. ### Branch naming -Prefer initializing stacks with a prefix (`-p`). Prefixes group branches under a namespace (e.g., `feat/auth`, `feat/api`) and keep branch names clean and consistent. When a prefix is set, pass only the suffix to subsequent `add` calls — the prefix is applied automatically. Without a prefix, you'll need to pass the full branch name each time. +Choose a clear, descriptive branch name for each layer that reflects the concern it contains (e.g., `auth`, `api-routes`, `frontend`). Branch names are used exactly as you provide them to `init` and `add` — nothing is prepended or transformed. Slashes are allowed and are treated as part of the name (e.g., `gh stack add refactor/foo` creates a branch named `refactor/foo`). ### Staging changes deliberately The main reason to use `git add` and `git commit` directly is to control **which changes go into which branch**. When you have multiple files in your working tree, you can stage a subset for the current branch, commit them, then create a new branch and stage the rest there: ```bash -# You're on feat/data-models with several new files in your working tree. +# You're on data-models with several new files in your working tree. # Stage only the model files for this branch: git add internal/models/user.go internal/models/session.go git commit -m "Add user and session models" @@ -109,7 +108,7 @@ git add db/migrations/001_create_users.sql git commit -m "Add user table migration" # Now create a new branch for the API layer and stage the API files there: -gh stack add api-routes # created & switched to feat/api-routes branch +gh stack add api-routes # created & switched to the api-routes branch git add internal/api/routes.go internal/api/handlers.go git commit -m "Add user API routes" ``` @@ -139,12 +138,11 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | Task | Command | |------|---------| -| Create a stack (recommended) | `gh stack init -p feat auth` | -| Create a stack without prefix | `gh stack init auth` | +| Create a stack | `gh stack init auth` | | Create a stack of multiple branches | `gh stack init auth api frontend` | | Adopt existing branches | `gh stack init existing-branch-a existing-branch-b` | | Set custom trunk | `gh stack init --base develop branch-a` | -| Add a branch to stack (suffix only if prefix set) | `gh stack add api-routes` | +| Add a branch to stack | `gh stack add api-routes` | | Add branch + stage all + commit | `gh stack add -Am "message" api-routes` | | Push branches to remote | `gh stack push` | | Push to specific remote | `gh stack push --remote origin` | @@ -161,9 +159,11 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current | View stack details (JSON) | `gh stack view --json` | | Switch branches up/down in stack | `gh stack up [n]` / `gh stack down [n]` | | Switch to top/bottom branch | `gh stack top` / `gh stack bottom` | +| Check out by stack number | `gh stack checkout 7` | | Check out by PR | `gh stack checkout 42` | | Check out by branch (local only) | `gh stack checkout feature-auth` | -| Tear down a stack to restructure it | `gh stack unstack` | +| Tear down the current stack to restructure it | `gh stack unstack` | +| Tear down a specific stack by number | `gh stack unstack 7` | --- @@ -173,8 +173,8 @@ Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current ```bash # 1. Initialize a stack with the first branch -gh stack init -p feat auth -# → creates feat/auth and checks it out +gh stack init auth +# → creates auth and checks it out # 2. Write code for the first layer (auth) cat > auth.go << 'EOF' @@ -205,7 +205,7 @@ git commit -m "Add auth middleware tests" # 4. When you're ready for a new concern, add the next branch gh stack add api-routes -# → creates feat/api-routes (prefix applied automatically — just pass the suffix) +# → creates api-routes # 5. Write code for the API layer cat > api.go << 'EOF' @@ -220,7 +220,7 @@ git commit -m "Add API routes" # 6. Add a third layer for frontend gh stack add frontend -# → creates feat/frontend (just the suffix — prefix is automatic) +# → creates frontend cat > frontend.go << 'EOF' package frontend @@ -232,7 +232,7 @@ EOF git add frontend.go git commit -m "Add frontend dashboard" -# ── Stack complete: feat/auth → feat/api-routes → feat/frontend ── +# ── Stack complete: auth → api-routes → frontend ── # 7. Push everything and create PRs (drafts by default) gh stack submit --auto @@ -248,11 +248,11 @@ gh stack view --json This is a critical workflow for agents. When you're working on a higher layer and realize you need to change something in a lower layer (e.g., you're building frontend components but need to add an API endpoint), **navigate down to the correct branch, make the change there, and rebase**. ```bash -# You're on feat/frontend but need to add an API endpoint +# You're on frontend but need to add an API endpoint # 1. Navigate to the API branch gh stack down -# or: gh stack checkout feat/api-routes +# or: gh stack checkout api-routes # 2. Make the change where it belongs cat > users_api.go << 'EOF' @@ -270,7 +270,7 @@ gh stack rebase --upstack # 4. Navigate back to where you were working gh stack top -# or: gh stack checkout feat/frontend +# or: gh stack checkout frontend # 5. Continue working — the API changes are now available ``` @@ -284,7 +284,7 @@ When you need to revisit a branch after the initial creation (e.g., responding t ```bash # 1. Navigate to the branch that needs changes gh stack bottom -# or: gh stack checkout feat/auth +# or: gh stack checkout auth # or: gh stack checkout 42 (by PR number) # 2. Make changes and commit @@ -305,7 +305,7 @@ gh stack push ### Routine sync after merges ```bash -# Single command: fetch, rebase, push, sync PR state +# Single command: fetch, rebase, push, sync PR and stack state gh stack sync # Sync and automatically clean up local branches for merged PRs @@ -314,23 +314,25 @@ gh stack sync --prune > **Note for agents:** In non-interactive environments, the prune prompt is not shown. Use `--prune` explicitly to delete local branches for merged PRs. +> **Note for agents:** `sync` also mirrors the stack on GitHub locally. If PRs were added to the stack on github.com, their branches are pulled down and appended to the local stack automatically. If the local and remote stacks have **diverged** (you changed the local stack while the remote stack changed differently), sync can only prompt to resolve it in an interactive terminal — in non-interactive environments it aborts the sync (nothing is pushed or updated) and exits successfully with `ℹ Sync aborted`. Resolve a divergence by unstacking and recreating the stack. + ### Squash-merge recovery When a PR is squash-merged on GitHub, the original branch's commits no longer exist in the trunk history. `gh stack` detects this automatically and uses `git rebase --onto` to correctly replay remaining commits. ```bash -# After PR #1 (feat/auth) is squash-merged on GitHub: +# After PR #1 (auth) is squash-merged on GitHub: gh stack sync # → fetches latest, detects the merge, fast-forwards trunk -# → rebases feat/api-routes onto updated trunk (skips merged branch) -# → rebases feat/frontend onto feat/api-routes +# → rebases api-routes onto updated trunk (skips merged branch) +# → rebases frontend onto api-routes # → pushes updated branches # → reports: "Merged: #1" # Verify the result gh stack view --json -# → feat/auth shows "isMerged": true, "state": "MERGED" -# → feat/api-routes and feat/frontend show updated heads +# → auth shows "isMerged": true, "state": "MERGED" +# → api-routes and frontend show updated heads ``` If `sync` hits a conflict during this process, it restores all branches to their pre-rebase state and exits with code 3. See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow. @@ -411,15 +413,11 @@ gh stack init [flags] ``` ```bash -# Set a branch prefix (recommended — subsequent `add` calls only need the suffix) -gh stack init -p feat auth -# → creates feat/auth - -# Multi-part prefix (slashes are fine — suffix-only rule still applies) -gh stack init -p monalisa/billing auth -# → creates monalisa/billing/auth +# Create a stack with a new branch +gh stack init auth +# → creates auth and checks it out -# Create a stack with new branches (no prefix — use full branch names) +# Create a stack with new branches gh stack init branch-a branch-b branch-c # Use a different trunk branch @@ -432,11 +430,10 @@ gh stack init branch-a branch-b branch-c | Flag | Description | |------|-------------| | `-b, --base ` | Trunk branch (defaults to the repo's default branch) | -| `-p, --prefix ` | Branch name prefix. Subsequent `add` calls only need the suffix (e.g., with `-p feat`, `gh stack add auth` creates `feat/auth`) | **Behavior:** -- Using `-p` is recommended — it simplifies branch naming for subsequent `add` calls +- Branch names are created exactly as given (slashes are allowed and kept as-is) - Creates any branches that don't already exist (branching from the trunk branch) - Existing branches are adopted automatically; missing branches are created from the trunk - Checks out the last branch in the list @@ -455,7 +452,7 @@ gh stack add [flags] **Recommended workflow — create the branch, then use standard git:** ```bash -# Create a new branch and switch to it (just the suffix — prefix is applied automatically) +# Create a new branch and switch to it gh stack add api-routes # Write code, stage deliberately, and commit @@ -487,7 +484,7 @@ gh stack add -um "Fix auth bug" auth-fix - `-A` and `-u` are mutually exclusive. - When the current branch has no commits (e.g., right after `init`), `add -Am` commits directly on the current branch instead of creating a new one. -- **Prefix handling:** Only pass the suffix when a prefix is set. `gh stack add api` with prefix `todo` → `todo/api`. Passing `todo/api` creates `todo/todo/api`. Without a prefix, pass the full branch name. +- **Branch names are used verbatim.** `gh stack add refactor/foo` creates a branch named `refactor/foo` — names are never prefixed or transformed. When `-m` is given without a branch name, the name is auto-generated from the commit message in date+slug format (e.g., `03-24-add_api_routes`). - If called from a branch that is not the topmost in the stack, exits with code 5: `"can only add branches on top of the stack"`. Use `gh stack top` to switch first. - **Uncommitted changes:** When using `gh stack add branch-name` without `-Am`, any uncommitted changes (staged or unstaged) in your working tree carry over to the new branch. This is standard git behavior — the working tree is not touched. Commit or stash changes on the current branch before running `add` if you want a clean starting point on the new branch. @@ -569,7 +566,7 @@ gh stack submit --auto --open Link PRs into a stack on GitHub without creating any local tracking state. This is the recommended approach if you are managing stacked branches with other tools (jj, Sapling, git-town) and want to simply create GitHub Stacked PRs via an API. ``` -gh stack link [flags] [...] +gh stack link [flags] [...] ``` ```bash @@ -584,8 +581,14 @@ gh stack link 10 20 30 # Add branches to an existing stack of PRs gh stack link 42 43 feature-auth feature-ui + +# Append to the top of an existing stack by its stack number +# (7 is a stack number; only the new PRs/branches are listed) +gh stack link 7 48 feature-auth ``` +When the first argument is a stack number, the remaining arguments are appended to the top of that stack, so you don't have to re-list its current PRs. Arguments already in the stack are skipped; arguments in a different stack are rejected. A numeric first argument is treated as a stack only when it matches an existing stack — otherwise it is a PR or branch. + | Flag | Description | |------|---------| | `--base ` | Base branch for the bottom of the stack (default: `main`) | @@ -628,16 +631,20 @@ gh stack sync [flags] **What it does (in order):** 1. **Fetch** latest changes from the remote -2. **Fast-forward trunk** to match remote (skips if already up to date, warns if diverged) -3. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow -4. **Push** all active branches atomically -5. **Sync PR state** from GitHub and report the status of each PR -6. **Sync the stack object** — link the open PRs into a stack on GitHub. If the PRs are not yet in a stack, a new stack is created; if some PRs are already in a stack, it is updated (additive only). This only happens when two or more PRs exist. Sync **never opens PRs** — use `gh stack submit` for that -7. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly +2. **Reconcile the remote stack** — mirror the GitHub stack locally. If PRs were added to the stack on GitHub, pull their branches down and append them to the local stack. If the local and remote stacks have diverged, aborts the sync in a non-interactive terminal. In an interactive terminal, offers prompts to resolve any divergence (replace local stack with remote version, delete stack on GitHub so it can be recreated, or cancel). +3. **Fast-forward trunk** to match remote (skips if already up to date, warns if diverged) +4. **Cascade rebase** all stack branches onto their updated parents (only if trunk moved). Handles merged PRs automatically. If a conflict is detected, **all branches are restored** to their pre-rebase state and the command exits with code 3 — see [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) for the resolution workflow +5. **Push** all active branches atomically +6. **Sync PR state** from GitHub and report the status of each PR +7. **Sync the stack object** — link the open PRs into a stack on GitHub. If the PRs are not yet in a stack, a new stack is created; if some PRs are already in a stack, it is updated (additive only). This only happens when two or more PRs exist. Sync **never opens PRs** — use `gh stack submit` for that +8. **Prune** — in interactive terminals, prompts to delete local branches for merged PRs. Use `--prune` to skip the prompt. In non-interactive environments, pruning only happens when `--prune` is passed explicitly **Output (stderr):** - `✓ Fetched latest changes from origin` +- `Pulling N new branches from the remote stack ...` then `✓ Pulled N new branches into the stack from the remote` (when the remote stack is ahead) +- `⚠ Your local stack has diverged from the stack on GitHub` (with `Local:` / `Remote:` chains) when the stacks have diverged +- `ℹ Sync aborted — no changes were made` when a sync is cancelled - `✓ Trunk main fast-forwarded to ` or `✓ Trunk main is already up to date` - `✓ Rebased onto ` per branch (if base moved) - `✓ Pushed N branches` @@ -645,7 +652,7 @@ gh stack sync [flags] - `Merged: #N, #M` for merged branches - `✓ Stack created on GitHub with N PRs` / `✓ Stack updated on GitHub with N PRs` / `✓ Linked to the existing stack on GitHub` (when two or more PRs exist) - `✓ Pruned (merged)` per pruned branch (when pruning) -- `✓ Stack synced` when the stack object on GitHub was created/updated to match local, or `✓ Branches synced` when only the branches were synced (fewer than two PRs, stacked PRs unavailable, or a divergence) +- `✓ Stack synced` when the stack object on GitHub was created/updated to match local, or `✓ Branches synced` when only the branches were synced (fewer than two PRs or stacked PRs unavailable) --- @@ -718,11 +725,10 @@ gh stack view --json ```json { "trunk": "main", - "prefix": "feat", - "currentBranch": "feat/api-routes", + "currentBranch": "api-routes", "branches": [ { - "name": "feat/auth", + "name": "auth", "head": "abc1234...", "base": "def5678...", "isCurrent": false, @@ -735,7 +741,7 @@ gh stack view --json } }, { - "name": "feat/api-routes", + "name": "api-routes", "head": "789abcd...", "base": "abc1234...", "isCurrent": true, @@ -807,24 +813,31 @@ When a branch name is provided, the command resolves it against locally tracked Tear down a stack so you can restructure it — remove a branch, reorder branches, rename branches, or make other large changes. After unstacking, use `gh stack init` to re-create the stack with the desired structure. -You must have a branch from the stack checked out locally. The command targets the active stack — the one that contains the currently checked out branch. +With no argument, the command targets the active stack — the one containing the currently checked out branch — unstacking it on GitHub and removing local tracking. + +Provide a stack number to unstack a specific stack on GitHub. This works from anywhere in the repository, whether or not the stack is checked out locally — the number is unstacked directly through the GitHub API (like `gh stack link`, no local tracking required). If the stack is also tracked locally, its local tracking is removed as well. ``` -gh stack unstack [flags] +gh stack unstack [] [flags] ``` ```bash -# Tear down the stack (locally and on GitHub), then rebuild +# Tear down the current stack (locally and on GitHub), then rebuild gh stack unstack gh stack init --base main branch-2 branch-1 branch-3 # reordered +# Unstack a specific stack by its number, from anywhere in the repo +gh stack unstack 7 + # Only remove local tracking (keep the stack on GitHub) gh stack unstack --local ``` | Flag | Description | |------|-------------| -| `--local` | Only delete the stack locally (keep it on GitHub) | +| `--local` | Only remove the stack locally (keep it on GitHub); never contacts GitHub | + +> **Note for agents:** `gh stack unstack ` is a remote-first API wrapper — it unstacks on GitHub by number from anywhere in the repo, tracked locally or not, and is safe for non-interactive use. `--local` never contacts GitHub; combining `--local` with a number that isn't tracked locally is an error. An unknown stack number returns a "not found on GitHub" error (exit code 2). ---