diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d63a842..f67c8b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ name: release + on: push: tags: - "v*" + permissions: contents: write id-token: write @@ -13,7 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - uses: cli/gh-extension-precompile@v2 with: generate_attestations: true go_version_file: go.mod + go_build_options: >- + -ldflags '-X github.com/github/gh-stack/cmd.Version=${{ steps.version.outputs.version }}' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7c65fe1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Vet + run: go vet ./... + - name: Test + run: go test -race -count=1 ./... diff --git a/README.md b/README.md index f79d62a..9984ccd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,469 @@ # gh-stack -A GitHub CLI extension to manage stacked branches and PRs + +A GitHub CLI extension for managing stacked branches and pull requests. + +Stacked PRs break large changes into a chain of small, reviewable pull requests that build on each other. `gh stack` automates the tedious parts — creating branches, keeping them rebased, setting correct PR base branches, and navigating between layers. + +## Installation + +```sh +gh extension install github/gh-stack +``` + +Requires the [GitHub CLI](https://cli.github.com/) (`gh`) v2.0+. + +## AI agent integration + +Install the gh-stack skill so your AI coding agent knows how to work with stacked PRs and the `gh stack` CLI: + +```sh +npx skills add github/gh-stack +``` + +## Quick start + +```sh +# Start a new stack from the default branch +gh stack init + +# Create the first branch and start working +gh stack add auth-layer +# ... make commits ... + +# Add another branch on top +gh stack add api-endpoints +# ... make commits ... + +# Push all branches and create/update PRs +gh stack push + +# View the stack +gh stack view +``` + +## How it works + +A **stack** is an ordered list of branches where each branch builds on the one below it. The **bottom** of the stack is based on a **trunk** branch (typically `main`). + +``` +frontend → PR #3 (base: api-endpoints) ← top +api-endpoints → PR #2 (base: auth-layer) +auth-layer → PR #1 (base: main) ← bottom +───────────── +main (trunk) +``` + +The **bottom** of the stack is the branch closest to the trunk, and the **top** is the branch furthest from it. 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. + +When you push, `gh stack` creates one PR per branch. Each PR's base is set to the branch below it in the stack, so reviewers see only the diff for that layer. + +### Local tracking + +Stack metadata is stored in `.git/gh-stack` (a JSON file, not committed to the repo). This tracks which branches belong to which stack and their ordering. Rebase state during interrupted rebases is stored separately in `.git/gh-stack-rebase-state`. + +## Commands + +### `gh stack init` + +Initialize a new stack in the current repository. + +``` +gh stack init [branches...] [flags] +``` + +Creates an entry in `.git/gh-stack` to track stack state. In interactive mode (no arguments), prompts you to name branches and offers to use the current branch as the first layer. In interactive mode, you'll also be prompted to set an optional branch prefix (unless adopting existing branches). When a prefix is set, branch names you enter are automatically prefixed. When explicit branch names are given, creates any that don't already exist (branching from the trunk). 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) | +| `-a, --adopt` | Adopt existing branches into a stack instead of creating new ones | +| `-p, --prefix ` | Set a branch name prefix for the stack | +| `-n, --numbered` | Use auto-incrementing numbered branch names (requires `--prefix`) | + +**Examples:** + +```sh +# Interactive — prompts for branch names +gh stack init + +# Non-interactive — specify branches upfront +gh stack init feature-auth feature-api feature-ui + +# Use a different trunk branch +gh stack init --base develop feature-auth + +# Adopt existing branches into a stack +gh stack init --adopt 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` + +Add a new branch on top of the current stack. + +``` +gh stack add [branch] [flags] +``` + +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`). + +| Flag | Description | +|------|-------------| +| `-A, --all` | Stage all changes (including untracked files); requires `-m` | +| `-u, --update` | Stage changes to tracked files only; requires `-m` | +| `-m, --message ` | Create a commit with this message before creating the branch | + +> **Note:** `-A` and `-u` are mutually exclusive. + +**Examples:** + +```sh +# Create a branch by name +gh stack add api-routes + +# Prompt for a branch name interactively +gh stack add + +# Stage all changes, commit, and auto-generate the branch name +gh stack add -Am "Add login endpoint" + +# Stage only tracked files, commit, and auto-generate the branch name +gh stack add -um "Fix auth bug" + +# Commit already-staged changes and auto-generate the branch name +gh stack add -m "Add user model" + +# Stage all changes, commit, and use an explicit branch name +gh stack add -Am "Add tests" test-layer + +# Stage only tracked files, commit, and use an explicit branch name +gh stack add -um "Update docs" docs-layer + +# Commit already-staged changes and use an explicit branch name +gh stack add -m "Refactor utils" cleanup-layer +``` + +### `gh stack checkout` + +Check out a locally tracked stack from a pull request number or branch name. + +``` +gh stack checkout [] +``` + +Resolves the target against stacks stored in local tracking (`.git/gh-stack`). Accepts a PR number (e.g. `42`) or a branch name that belongs to a locally tracked stack. When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. + +> **Note:** Server-side stack discovery is not yet implemented. This command currently only works with stacks that have been created locally (via `gh stack init`). Checking out a stack that is not tracked locally will require passing in an explicit branch name or PR number once the server API is available. + +**Examples:** + +```sh +# Check out a stack by PR number +gh stack checkout 42 + +# Check out a stack by branch name +gh stack checkout feature-auth + +# Interactive — select from locally tracked stacks +gh stack checkout +``` + +### `gh stack rebase` + +Pull from remote and do a cascading rebase across the stack. + +``` +gh stack rebase [branch] [flags] +``` + +Fetches the latest changes from `origin`, then ensures each branch in the stack has the tip of the previous layer in its commit history. Rebases branches in order from trunk upward. If a branch's PR has been squash-merged, the rebase automatically switches to `--onto` mode to correctly replay commits on top of the merge target. + +If a rebase conflict occurs, the operation pauses and prints the conflicted files with line numbers. Resolve the conflicts, stage with `git add`, and continue with `--continue`. To undo the entire rebase, use `--abort` to restore all branches to their pre-rebase state. + +| Flag | Description | +|------|-------------| +| `--downstack` | Only rebase branches from trunk to the current branch | +| `--upstack` | Only rebase branches from the current branch to the top | +| `--continue` | Continue the rebase after resolving conflicts | +| `--abort` | Abort the rebase and restore all branches to their pre-rebase state | +| `--remote ` | Remote to fetch from (defaults to auto-detected remote) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | Target branch (defaults to the current branch) | + +**Examples:** + +```sh +# Rebase the entire stack +gh stack rebase + +# Only rebase branches below the current one +gh stack rebase --downstack + +# Only rebase branches above the current one +gh stack rebase --upstack + +# After resolving a conflict +gh stack rebase --continue + +# Give up and restore everything +gh stack rebase --abort +``` + +### `gh stack sync` + +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: + +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 + +| Flag | Description | +|------|-------------| +| `--remote ` | Remote to fetch from and push to (defaults to auto-detected remote) | + +**Examples:** + +```sh +gh stack sync +``` + +### `gh stack push` + +Push all branches in the current stack and create or update pull requests. + +``` +gh stack push [flags] +``` + +Pushes every branch to the remote, then for each branch either creates a new PR (with the correct base branch) or updates the base of an existing PR if it has changed. Uses `--force-with-lease` by default to safely update rebased branches. + +When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely. + +| Flag | Description | +|------|-------------| +| `--auto` | Use auto-generated PR titles without prompting | +| `--draft` | Create new PRs as drafts | +| `--skip-prs` | Push branches without creating or updating PRs | +| `--remote ` | Remote to push to (defaults to auto-detected remote) | + +**Examples:** + +```sh +gh stack push +gh stack push --auto +gh stack push --draft +gh stack push --skip-prs +``` + +### `gh stack view` + +View the current stack. + +``` +gh stack view [flags] +``` + +Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`). + +| Flag | Description | +|------|-------------| +| `-s, --short` | Compact output (branch names only) | +| `--json` | Output stack data as JSON | + +**Examples:** + +```sh +gh stack view +gh stack view --short +gh stack view --json +``` + +### `gh stack unstack` + +Remove a stack from local tracking and optionally delete it on GitHub. + +``` +gh stack unstack [branch] [flags] +``` + +If no branch is specified, uses the current branch to find the stack. By default, the stack is removed from both local tracking and GitHub. Use `--local` to only remove the local tracking entry. + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (keep it on GitHub) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack to delete (defaults to the current branch) | + +**Examples:** + +```sh +# Remove the stack from local tracking and GitHub +gh stack unstack + +# Only remove local tracking +gh stack unstack --local + +# Specify a branch to identify the stack +gh stack unstack feature-auth +``` + +### `gh stack merge` + +Merge a stack of PRs. + +``` +gh stack merge +``` + +Merges the specified PR and all PRs below it in the stack. + +> **Note:** This command is not yet implemented. Running it prints a notice. + +### Navigation + +Move between branches in the current stack without having to remember branch names. + +```sh +gh stack up [n] # Move up n branches (default 1) +gh stack down [n] # Move down n branches (default 1) +gh stack top # Jump to the top of the stack +gh stack bottom # Jump to the bottom of the stack +``` + +Navigation commands clamp to the bounds of the stack — moving up from the top or down from the bottom is a no-op with a message. If you're on the trunk branch, `up` moves to the first stack branch. + +**Examples:** + +```sh +gh stack up # move up one layer +gh stack up 3 # move up three layers +gh stack down +gh stack top +gh stack bottom +``` + +### `gh stack feedback` + +Share feedback about gh-stack. + +``` +gh stack feedback [title] +``` + +Opens a GitHub Discussion in the [gh-stack repository](https://github.com/github/gh-stack) to submit feedback. Optionally provide a title for the discussion post. + +**Examples:** + +```sh +gh stack feedback +gh stack feedback "Support for reordering branches" +``` + +## Typical workflow + +```sh +# 1. Start a stack +gh stack init +gh stack add auth-middleware + +# 2. Work on the first layer +# ... write code, make commits ... + +# 3. Add the next layer +gh stack add api-routes +# ... write code, make commits ... + +# 4. Push everything and create PRs +gh stack push + +# 5. Reviewer requests changes on the first PR +gh stack bottom +# ... make changes, commit ... + +# 6. Rebase the rest of the stack on top of your fix +gh stack rebase + +# 7. Push the updated stack +gh stack push + +# 8. When the first PR is merged, sync the stack +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. + +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 + +# 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 +# (no new branch is created) + +# 4. Write code for the next layer +# ... write code ... + +# 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 + +# 6. Keep going +# ... write code ... + +gh stack add -Am "Frontend components" +# → feat/02 already has commits, creates feat/03 and commits there + +# 7. Push everything and create PRs +gh stack push +``` + +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. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Generic error | +| 2 | Not in a stack / stack not found | +| 3 | Rebase conflict | +| 4 | GitHub API failure | +| 5 | Invalid arguments or flags | +| 6 | Disambiguation required (branch belongs to multiple stacks) | +| 7 | Rebase already in progress | diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..9cb8dd0 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/branch" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type addOptions struct { + stageAll bool + stageTracked bool + message string +} + +func AddCmd(cfg *config.Config) *cobra.Command { + opts := &addOptions{} + + cmd := &cobra.Command{ + Use: "add [branch]", + Short: "Add a new branch on top of the current stack", + Long: `Add a new branch on top of the current stack. + +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.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAdd(cfg, opts, args) + }, + } + + cmd.Flags().BoolVarP(&opts.stageAll, "all", "A", false, "Stage all changes including untracked files") + cmd.Flags().BoolVarP(&opts.stageTracked, "update", "u", false, "Stage changes to tracked files only") + cmd.Flags().StringVarP(&opts.message, "message", "m", "", "Create a commit with this message") + + return cmd +} + +func runAdd(cfg *config.Config, opts *addOptions, args []string) error { + // Validate flag combinations + if opts.stageAll && opts.stageTracked { + cfg.Errorf("flags -A and -u are mutually exclusive") + return ErrInvalidArgs + } + + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + if s.IsFullyMerged() { + cfg.Warningf("All branches in this stack have been merged") + cfg.Printf("Consider creating a new stack with `%s`", cfg.ColorCyan("gh stack init")) + return nil + } + + idx := s.IndexOf(currentBranch) + // idx < 0 means we're on the trunk — that's allowed (we'll create + // a new branch from it). Only block if we're in the middle of the stack. + if idx >= 0 && idx < len(s.Branches)-1 { + cfg.Errorf("can only add branches on top of the stack; run `%s` to switch to %q", cfg.ColorCyan("gh stack top"), s.Branches[len(s.Branches)-1].Branch) + return ErrInvalidArgs + } + + // Check if the current branch is a stack branch with no unique commits + // relative to its parent. If so, the commit should land on this branch + // without creating a new one (e.g., right after init). + wantsCommit := opts.message != "" || opts.stageAll || opts.stageTracked + var branchIsEmpty bool + if wantsCommit && idx >= 0 { + parentBranch := s.ActiveBaseBranch(currentBranch) + shas, err := git.RevParseMulti([]string{parentBranch, currentBranch}) + if err == nil { + branchIsEmpty = shas[0] == shas[1] + } + } + + // Empty branch path: stage and commit here, don't create a new branch. + if branchIsEmpty { + if err := stageAndValidate(cfg, opts); err != nil { + return ErrSilent + } + sha, err := doCommit(opts.message) + if err != nil { + cfg.Errorf("failed to commit: %s", err) + return ErrSilent + } + cfg.Successf("Created commit %s on %s", cfg.ColorBold(sha), currentBranch) + cfg.Warningf("Branch %s has no prior commits — adding your commit here instead of creating a new branch", currentBranch) + cfg.Printf("When you're ready for the next layer, run `%s` again", cfg.ColorCyan("gh stack add")) + return nil + } + + // Resolve branch 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 == "" { + 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 { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + for { + input, err := p.Input("Enter a name for the new branch", "") + 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 + } + branchName = applyPrefix(cfg, s.Prefix, input) + break + } + } + } + + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return ErrInvalidArgs + } + + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in the stack", branchName) + return ErrInvalidArgs + } + + if git.BranchExists(branchName) { + cfg.Errorf("branch %q already exists", branchName) + return ErrInvalidArgs + } + + // Stage changes before creating the branch so we can fail early if + // there's nothing to commit (avoids leaving an empty orphan branch). + if wantsCommit { + if err := stageAndValidate(cfg, opts); err != nil { + return ErrSilent + } + } + + // Create the new branch from the current HEAD and check it out + if err := git.CreateBranch(branchName, currentBranch); err != nil { + cfg.Errorf("failed to create branch: %s", err) + return ErrSilent + } + + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("failed to checkout branch: %s", err) + return ErrSilent + } + + base, err := git.RevParse(currentBranch) + if err != nil { + cfg.Warningf("could not resolve base SHA for %s: %s", currentBranch, err) + } + s.Branches = append(s.Branches, stack.BranchRef{Branch: branchName, Base: base}) + + // Commit on the NEW branch (staging already done above) + var commitSHA string + if wantsCommit { + sha, err := doCommit(opts.message) + if err != nil { + cfg.Errorf("failed to commit: %s", err) + return ErrSilent + } + commitSHA = sha + } + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return ErrSilent + } + + // Print summary + position := len(s.Branches) + if commitSHA != "" { + cfg.Successf("Created branch %s (layer %d) with commit %s", cfg.ColorBold(branchName), position, commitSHA) + } else { + cfg.Successf("Created and checked out branch %q", branchName) + } + + return nil +} + +// stageAndValidate stages files (if -A or -u is set) and verifies there are +// staged changes to commit. Prints a user-facing error and returns non-nil +// if staging fails or there is nothing to commit. +func stageAndValidate(cfg *config.Config, opts *addOptions) error { + if opts.stageAll { + if err := git.StageAll(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return err + } + } else if opts.stageTracked { + if err := git.StageTracked(); err != nil { + cfg.Errorf("failed to stage changes: %s", err) + return err + } + } + + if !git.HasStagedChanges() { + if opts.stageAll || opts.stageTracked { + cfg.Errorf("no changes to commit after staging") + } else { + cfg.Errorf("nothing to commit; stage changes first or use -A/-u") + } + return fmt.Errorf("nothing to commit") + } + return nil +} + +// doCommit commits staged changes. If message is provided, uses it directly. +// If message is empty, launches the user's editor via git commit. +func doCommit(message string) (string, error) { + if message != "" { + return git.Commit(message) + } + 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 new file mode 100644 index 0000000..e6238c0 --- /dev/null +++ b/cmd/add_test.go @@ -0,0 +1,363 @@ +package cmd + +import ( + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// saveStack is a helper to pre-create a stack file for add tests. +func saveStack(t *testing.T, gitDir string, s stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s}, + } + require.NoError(t, stack.Save(gitDir, sf), "saving seed stack") +} + +func TestAdd_CreatesNewBranch(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + var createdBranch, checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "newbranch", createdBranch, "CreateBranch") + assert.Equal(t, "newbranch", checkedOut, "CheckoutBranch") + + sf, err := stack.Load(gitDir) + require.NoError(t, err, "loading stack") + names := sf.Stacks[0].BranchNames() + assert.Equal(t, "newbranch", names[len(names)-1], "top branch") +} + +func TestAdd_OnlyAllowedOnTopOfStack(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "top of the stack") +} + +func TestAdd_MutuallyExclusiveFlags(t *testing.T) { + restore := git.SetOps(&git.MockOps{}) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, stageTracked: true, message: "msg"}, []string{"branch"}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "mutually exclusive") +} + +func TestAdd_StagingWithoutMessageUsesEditor(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + interactiveCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "bbb"}, nil + }, + RevParseFn: func(ref string) (string, error) { return "abc", nil }, + CreateBranchFn: func(name, base string) error { return nil }, + CheckoutBranchFn: func(name string) error { return nil }, + StageAllFn: func() error { return nil }, + HasStagedChangesFn: func() bool { return true }, + CommitInteractiveFn: func() (string, error) { + interactiveCalled = true + return "def1234567890", nil + }, + }) + defer restore() + + cfg, _, _ := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true}, []string{"new-branch"}) + + assert.True(t, interactiveCalled, "expected CommitInteractive to be called when -m is omitted") +} + +func TestAdd_EmptyBranchCommitsInPlace(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + createBranchCalled := false + commitCalled := false + stageAllCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + // Return same SHA for parent and current branch — branch has no unique commits + return []string{"aaa111", "aaa111"}, nil + }, + StageAllFn: func() error { + stageAllCalled = true + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + commitCalled = true + return "abc1234567890", nil + }, + CreateBranchFn: func(name, base string) error { + createBranchCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "Auth middleware"}, nil) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + assert.True(t, stageAllCalled, "expected StageAll to be called") + assert.True(t, commitCalled, "expected Commit to be called") + assert.False(t, createBranchCalled, "CreateBranch should NOT be called for empty branch commit-in-place") +} + +func TestAdd_BranchWithCommitsCreatesNew(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + createCalled := false + checkoutCalled := false + commitCalled := false + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + // Parent and current branch point to different commits (branch has commits) + return []string{"aaa", "bbb"}, nil + }, + CreateBranchFn: func(name, base string) error { + createCalled = true + return nil + }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + commitCalled = true + return "def1234567890", nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "API routes"}, nil) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + assert.True(t, createCalled, "expected CreateBranch to be called") + assert.True(t, checkoutCalled, "expected CheckoutBranch to be called") + assert.True(t, commitCalled, "expected Commit to be called on the new branch") +} + +func TestAdd_PrefixAppliedWithSlash(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 + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"mybranch"}) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "feat/mybranch", createdBranch) +} + +func TestAdd_NumberedNaming(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"}}, + }) + + var createdBranch string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "feat/01", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "bbb"}, nil + }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + HasStagedChangesFn: func() bool { return true }, + CommitFn: func(msg string) (string, error) { + return "def1234567890", nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "next feature"}, nil) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + assert.Equal(t, "feat/02", createdBranch) +} + +func TestAdd_FullyMergedStackBlocked(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + 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}}, + }, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "All branches in this stack have been merged") +} + +func TestAdd_NothingToCommit(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + return []string{"aaa", "aaa"}, nil // same SHA = empty branch + }, + StageAllFn: func() error { return nil }, + HasStagedChangesFn: func() bool { return false }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runAdd(cfg, &addOptions{stageAll: true, message: "msg"}, nil) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "no changes to commit") +} + +func TestAdd_FromTrunk(t *testing.T) { + gitDir := t.TempDir() + saveStack(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + }) + + var createdBranch string + var checkedOut string + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CreateBranchFn: func(name, base string) error { + createdBranch = name + return nil + }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + err := runAdd(cfg, &addOptions{}, []string{"newbranch"}) + output := collectOutput(cfg, outR, errR) + + // When on trunk, idx < 0 so the middle-of-stack check passes. + // Add should succeed and create the new branch. + require.NoError(t, err) + assert.Equal(t, "newbranch", createdBranch) + assert.Equal(t, "newbranch", checkedOut) + assert.NotContains(t, output, "\u2717") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + names := sf.Stacks[0].BranchNames() + assert.Equal(t, "newbranch", names[len(names)-1], "new branch should be appended to stack") +} diff --git a/cmd/checkout.go b/cmd/checkout.go new file mode 100644 index 0000000..a32e1d8 --- /dev/null +++ b/cmd/checkout.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "errors" + "fmt" + + "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/stack" + "github.com/spf13/cobra" +) + +type checkoutOptions struct { + target string +} + +func CheckoutCmd(cfg *config.Config) *cobra.Command { + opts := &checkoutOptions{} + + cmd := &cobra.Command{ + Use: "checkout []", + Short: "Checkout a stack from a PR number or branch name", + Long: `Check out a stack from a pull request number or branch name. + +Currently resolves stacks from local tracking only (.git/gh-stack). +Accepts a PR number (e.g. 42) or a branch name that belongs to +a locally tracked stack. When run without arguments, shows a menu of +all locally available stacks to choose from. + +Server-side stack discovery will be added in a future release.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.target = args[0] + } + return runCheckout(cfg, opts) + }, + } + + return cmd +} + +// runCheckout resolves a stack from local tracking and checks out the target branch. +// +// Future behavior (once the server API is available): +// 1. Resolve the target (PR number, URL, or branch name) to a PR via the API +// 2. If the PR is part of a stack, discover the full set of PRs in the stack +// 3. Fetch and create local tracking branches for every branch in the stack +// 4. Save the stack to local tracking (.git/gh-stack, similar to gh stack init --adopt) +// 5. Switch to the target branch +func runCheckout(cfg *config.Config, opts *checkoutOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return ErrNotInStack + } + + var s *stack.Stack + var targetBranch string + + if opts.target == "" { + // Interactive picker mode + s, err = interactiveStackPicker(cfg, sf) + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return ErrSilent + } + if s == nil { + return nil + } + // Check out the top active branch of the selected stack + targetBranch = s.Branches[len(s.Branches)-1].Branch + } else { + // Resolve target against local stacks + var br *stack.BranchRef + s, br, err = resolvePR(sf, opts.target) + if err != nil { + cfg.Errorf("%s", err) + return ErrNotInStack + } + targetBranch = br.Branch + } + + currentBranch, _ := git.CurrentBranch() + if targetBranch == currentBranch { + cfg.Infof("Already on %s", targetBranch) + cfg.Printf("Stack: %s", s.DisplayChain()) + return nil + } + + if err := git.CheckoutBranch(targetBranch); err != nil { + cfg.Errorf("failed to checkout %s: %v", targetBranch, err) + return ErrSilent + } + + cfg.Successf("Switched to %s", targetBranch) + cfg.Printf("Stack: %s", s.DisplayChain()) + return nil +} + +// 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) { + if !cfg.IsInteractive() { + 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 specific branch/PR once server-side discovery is available.", cfg.ColorCyan("gh stack init")) + return nil, nil + } + + options := make([]string, len(sf.Stacks)) + for i := range sf.Stacks { + options[i] = sf.Stacks[i].DisplayChain() + } + + 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, + ) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, errInterrupt + } + return nil, fmt.Errorf("stack selection: %w", err) + } + + return &sf.Stacks[selected], nil +} diff --git a/cmd/checkout_test.go b/cmd/checkout_test.go new file mode 100644 index 0000000..1862699 --- /dev/null +++ b/cmd/checkout_test.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckout_ByBranchName(t *testing.T) { + 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 }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "b2"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b2", checkedOut) + assert.Contains(t, output, "Switched to b2") +} + +func TestCheckout_ByPRNumber(t *testing.T) { + 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 }, + CheckoutBranchFn: func(name string) error { + checkedOut = name + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "42"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Equal(t, "b1", checkedOut) + assert.Contains(t, output, "Switched to b1") +} + +func TestCheckout_AlreadyOnTarget(t *testing.T) { + gitDir := t.TempDir() + checkoutCalled := false + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkoutCalled = true + return nil + }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "b1"}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.False(t, checkoutCalled, "CheckoutBranch should not be called when already on target") + assert.Contains(t, output, "Already on b1") +} + +func TestCheckout_NoStacks_NonInteractive(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + // Write an empty stack file (no stacks) + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{}) // no target arg + output := collectOutput(cfg, outR, errR) + + assert.Error(t, err) + assert.Contains(t, output, "no target specified") +} + +func TestCheckout_BranchNotFound(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + writeStackFile(t, gitDir, stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runCheckout(cfg, &checkoutOptions{target: "nonexistent"}) + output := collectOutput(cfg, outR, errR) + + assert.ErrorIs(t, err, ErrNotInStack) + assert.Contains(t, output, "no locally tracked stack found") +} diff --git a/cmd/feedback.go b/cmd/feedback.go new file mode 100644 index 0000000..94195c1 --- /dev/null +++ b/cmd/feedback.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "net/url" + "strings" + + "github.com/cli/go-gh/v2/pkg/browser" + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +const feedbackBaseURL = "https://github.com/github/gh-stack/discussions/new?category=feedback" + +func FeedbackCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "feedback [title]", + Short: "Submit feedback for gh-stack", + Long: "Opens a GitHub Discussion in the gh-stack repository to submit feedback. Optionally provide a title for the discussion post.", + RunE: func(cmd *cobra.Command, args []string) error { + return runFeedback(cfg, args) + }, + } + + return cmd +} + +func runFeedback(cfg *config.Config, args []string) error { + feedbackURL := feedbackBaseURL + + if len(args) > 0 { + title := strings.Join(args, " ") + feedbackURL += "&title=" + url.QueryEscape(title) + } + + b := browser.New("", cfg.Out, cfg.Err) + if err := b.Browse(feedbackURL); err != nil { + return err + } + + cfg.Successf("Opening feedback form in your browser...") + return nil +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..c14d703 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,344 @@ +package cmd + +import ( + "errors" + "fmt" + "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" + "github.com/spf13/cobra" +) + +type initOptions struct { + branches []string + base string + adopt bool + prefix string + numbered bool +} + +func InitCmd(cfg *config.Config) *cobra.Command { + opts := &initOptions{} + + cmd := &cobra.Command{ + Use: "init [branches...]", + Short: "Initialize a new stack", + Long: `Initialize a stack object in the local repo. + +Unless specified, prompts user to create/select branch for first layer of the stack. +Trunk defaults to default branch, unless specified otherwise.`, + Example: ` $ gh stack init + $ gh stack init myBranch + $ gh stack init --adopt branch1 branch2 branch3 + $ gh stack init --base integrationBranch firstBranch`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.branches = args + return runInit(cfg, opts) + }, + } + + cmd.Flags().StringVarP(&opts.base, "base", "b", "", "Trunk branch for stack (defaults to default branch)") + cmd.Flags().BoolVarP(&opts.adopt, "adopt", "a", false, "Track existing branches as part of a stack") + 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)") + + return cmd +} + +func runInit(cfg *config.Config, opts *initOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + // Determine trunk branch + trunk := opts.base + + // Enable git rerere so conflict resolutions are remembered. + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return ErrSilent + } + + if trunk == "" { + trunk, err = git.DefaultBranch() + if err != nil { + cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch") + return ErrNotInStack + } + } + + // Load existing stack file + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return ErrNotInStack + } + + // Set repository context + repo, err := cfg.Repo() + if err == nil { + sf.Repository = repo.Host + ":" + repo.Owner + "/" + repo.Name + } + + currentBranch, _ := git.CurrentBranch() + + // Don't allow initializing a stack if the current branch is a non-trunk + // member of another stack. Trunk branches (e.g. "main") can be shared + // across multiple stacks. + if currentBranch != "" { + for _, s := range sf.FindAllStacksForBranch(currentBranch) { + if s.IndexOf(currentBranch) >= 0 { + cfg.Errorf("current branch %q is already part of a stack", currentBranch) + return ErrInvalidArgs + } + } + } + + var branches []string + + // Validate --numbered requires a prefix (either from flag or interactive input, + // but for non-interactive paths we can check early). + if opts.numbered && opts.prefix == "" && !cfg.IsInteractive() { + cfg.Errorf("--numbered requires --prefix") + return ErrInvalidArgs + } + + if opts.adopt { + // Adopt mode: validate all specified branches exist + if len(opts.branches) == 0 { + cfg.Errorf("--adopt requires at least one branch name") + return ErrInvalidArgs + } + for _, b := range opts.branches { + if !git.BranchExists(b) { + cfg.Errorf("branch %q does not exist", b) + return ErrInvalidArgs + } + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in a stack", b) + return ErrInvalidArgs + } + } + branches = opts.branches + + // Check if any adopted branches already have PRs on GitHub. + // If offline or unable to create client, skip silently. + if client, clientErr := cfg.GitHubClient(); clientErr == nil { + for _, b := range branches { + pr, err := client.FindAnyPRForBranch(b) + if err != nil { + continue + } + if pr != nil { + state := "open" + if pr.Merged { + state = "merged" + } + cfg.Errorf("branch %q already has a %s PR (#%d: %s)", b, state, pr.Number, pr.URL) + return ErrInvalidArgs + } + } + } + } else if len(opts.branches) > 0 { + // Explicit branch names provided — create them + for _, b := range opts.branches { + if err := sf.ValidateNoDuplicateBranch(b); err != nil { + cfg.Errorf("branch %q already exists in a stack", b) + return ErrInvalidArgs + } + if !git.BranchExists(b) { + if err := git.CreateBranch(b, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", b, err) + return ErrSilent + } + } + } + branches = opts.branches + } else { + // Interactive mode + if !cfg.IsInteractive() { + cfg.Errorf("interactive input required; provide branch names or use --adopt") + return ErrInvalidArgs + } + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + + // Step 1: Ask for prefix + if opts.prefix == "" { + if opts.numbered { + // --numbered requires a prefix; prompt specifically for one + prefixInput, err := p.Input("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 + } + } else { + prefixInput, err := p.Input("Set a branch prefix? (leave blank to skip)", "") + 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) + } + } + + // Step 2: Ask for branch name (unless --numbered auto-generates it) + if opts.numbered { + // Auto-generate numbered branch name + 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) { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return ErrSilent + } + } + branches = []string{branchName} + } else { + if currentBranch != "" && currentBranch != trunk { + // Already on a non-trunk branch — offer to use it + useCurrentBranch, err := p.Confirm( + fmt.Sprintf("Would you like to use %s as the first layer of your stack?", currentBranch), + true, + ) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to confirm branch selection: %s", err) + return ErrSilent + } + if useCurrentBranch { + if err := sf.ValidateNoDuplicateBranch(currentBranch); err != nil { + cfg.Errorf("branch %q already exists in the stack", currentBranch) + return ErrInvalidArgs + } + branches = []string{currentBranch} + } + } + + if len(branches) == 0 { + prompt := "What branch would you like to use as the first layer of your stack?" + if opts.prefix != "" { + prompt = fmt.Sprintf("Enter a name for the first branch (will be prefixed with %s/)", opts.prefix) + } + branchName, err := p.Input(prompt, "") + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + cfg.Errorf("failed to read branch name: %s", err) + return ErrSilent + } + branchName = strings.TrimSpace(branchName) + + if branchName == "" { + cfg.Errorf("branch name cannot be empty") + return ErrInvalidArgs + } + + if opts.prefix != "" { + branchName = opts.prefix + "/" + branchName + } + + if err := sf.ValidateNoDuplicateBranch(branchName); err != nil { + cfg.Errorf("branch %q already exists in a stack", branchName) + return ErrInvalidArgs + } + if !git.BranchExists(branchName) { + if err := git.CreateBranch(branchName, trunk); err != nil { + cfg.Errorf("creating branch %s: %s", branchName, err) + return ErrSilent + } + } + branches = []string{branchName} + } + } + } + + // Validate prefix (from flag or interactive input) + 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 + } + } + + // Build stack + trunkSHA, _ := git.RevParse(trunk) + branchRefs := make([]stack.BranchRef, len(branches)) + for i, b := range branches { + parent := trunk + if i > 0 { + parent = branches[i-1] + } + base, _ := git.MergeBase(b, parent) + branchRefs[i] = stack.BranchRef{Branch: b, Base: base} + } + + newStack := stack.Stack{ + Prefix: opts.prefix, + Numbered: opts.numbered, + Trunk: stack.BranchRef{ + Branch: trunk, + Head: trunkSHA, + }, + Branches: branchRefs, + } + + sf.AddStack(newStack) + + // Sync PR state for adopted branches + syncStackPRs(cfg, &sf.Stacks[len(sf.Stacks)-1]) + + if err := stack.Save(gitDir, sf); err != nil { + return err + } + + // Print result + if opts.adopt { + cfg.Printf("Adopting stack with trunk %s and %d branches", trunk, len(branches)) + cfg.Printf("Initializing stack: %s", newStack.DisplayChain()) + cfg.Printf("You can continue working on %s", branches[len(branches)-1]) + } else { + cfg.Successf("Creating stack with trunk %s and branch %s", trunk, branches[len(branches)-1]) + // Switch to last branch if not already there + lastBranch := branches[len(branches)-1] + if currentBranch != lastBranch { + if err := git.CheckoutBranch(lastBranch); err != nil { + cfg.Errorf("switching to branch %s: %s", lastBranch, err) + return ErrSilent + } + cfg.Printf("Switched to branch %s", lastBranch) + } else { + cfg.Printf("You can continue working on %s", lastBranch) + } + } + + cfg.Printf("To add a new layer to your stack, run `%s`", cfg.ColorCyan("gh stack add")) + cfg.Printf("When you're ready to push to GitHub and open a stack of PRs, run `%s`", cfg.ColorCyan("gh stack push")) + + return nil +} diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..773b078 --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "io" + "os" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// collectOutput closes the write ends of the test config pipes and returns +// the captured stderr content. Shared across cmd test files. +func collectOutput(cfg *config.Config, outR, errR *os.File) string { + cfg.Out.Close() + cfg.Err.Close() + stderr, _ := io.ReadAll(errR) + outR.Close() + errR.Close() + return string(stderr) +} + +func TestInit_CreatesStackWithCorrectTrunk(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"}}) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error in output") + + sf, err := stack.Load(gitDir) + require.NoError(t, err, "loading stack") + require.Len(t, sf.Stacks, 1) + s := sf.Stacks[0] + assert.Equal(t, "main", s.Trunk.Branch) + names := s.BranchNames() + require.Len(t, names, 1) + assert.Equal(t, "myBranch", names[0]) +} + +func TestInit_CustomTrunk(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"myBranch"}, base: "develop"}) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + + sf, err := stack.Load(gitDir) + require.NoError(t, err, "loading stack") + assert.Equal(t, "develop", sf.Stacks[0].Trunk.Branch) +} + +func TestInit_AdoptExistingBranches(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 }, + BranchExistsFn: func(string) bool { return true }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{ + branches: []string{"b1", "b2", "b3"}, + adopt: true, + }) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + + sf, err := stack.Load(gitDir) + require.NoError(t, err, "loading stack") + names := sf.Stacks[0].BranchNames() + 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_RerereAlreadyEnabled(t *testing.T) { + gitDir := t.TempDir() + enableRerereCalled := false + 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 }, + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + EnableRerereFn: func() error { + enableRerereCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"b1"}}) + collectOutput(cfg, outR, errR) + + assert.False(t, enableRerereCalled, "EnableRerere should not be called when rerere is already enabled") +} + +func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) { + gitDir := t.TempDir() + + // Pre-create stack file with "feature-1" as a non-trunk branch + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feature-1"}}, + }}, + } + require.NoError(t, stack.Save(gitDir, sf), "saving seed stack") + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + DefaultBranchFn: func() (string, error) { return "main", nil }, + CurrentBranchFn: func() (string, error) { return "feature-1", nil }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"newBranch"}}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "already part of a stack") +} + +func TestInit_AdoptNonexistentBranch(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 }, + BranchExistsFn: func(string) bool { return false }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"nonexistent"}, adopt: true}) + output := collectOutput(cfg, outR, errR) + + assert.Contains(t, output, "does not exist") +} + +func TestInit_MultipleBranches_CreatesAll(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 }, + CreateBranchFn: func(name, base string) error { + created = append(created, name) + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + runInit(cfg, &initOptions{branches: []string{"b1", "b2", "b3"}}) + output := collectOutput(cfg, outR, errR) + + require.NotContains(t, output, "\u2717", "unexpected error") + + sf, err := stack.Load(gitDir) + require.NoError(t, err, "loading stack") + names := sf.Stacks[0].BranchNames() + assert.Equal(t, []string{"b1", "b2", "b3"}, names) +} diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 0000000..74c452d --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/browser" + "github.com/cli/go-gh/v2/pkg/prompter" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +func MergeCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "merge []", + Short: "Merge a stack of PRs", + Long: `Merges the specified PR and all PRs below it in the stack. + +Accepts a PR URL, PR number, or branch name. When run without +arguments, operates on the current branch's PR.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var target string + if len(args) > 0 { + target = args[0] + } + return runMerge(cfg, target) + }, + } + + return cmd +} + +func runMerge(cfg *config.Config, target string) error { + // Standard stack loading and validation. + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + s := result.Stack + currentBranch := result.CurrentBranch + + // Sync PR state from GitHub so merge status is up to date. + syncStackPRs(cfg, s) + + // Persist the refreshed PR state. + _ = stack.Save(result.GitDir, result.StackFile) + + // Resolve which branch to operate on. + var br *stack.BranchRef + if target != "" { + _, br, err = resolvePR(result.StackFile, target) + if err != nil { + cfg.Errorf("%s", err) + return ErrNotInStack + } + } else { + idx := s.IndexOf(currentBranch) + if idx < 0 { + if s.IsFullyMerged() { + cfg.Successf("All PRs in this stack have already been merged") + return nil + } + cfg.Errorf("current branch %q is not a stack branch (it may be the trunk)", currentBranch) + return ErrNotInStack + } + br = &s.Branches[idx] + } + + if br.PullRequest == nil { + cfg.Errorf("no pull request found for branch %q", br.Branch) + cfg.Printf(" Run %s to create PRs for this stack.", cfg.ColorCyan("gh stack push")) + return ErrSilent + } + + if br.IsMerged() { + cfg.Successf("PR %s has already been merged", cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) + cfg.Printf(" %s", br.PullRequest.URL) + return nil + } + + prURL := br.PullRequest.URL + prLink := cfg.PRLink(br.PullRequest.Number, prURL) + + cfg.Warningf("Merging stacked PRs from the CLI is not yet supported") + + if cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + openWeb, promptErr := p.Confirm( + fmt.Sprintf("Open %s in your browser?", prLink), true) + if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return nil + } + cfg.Errorf("prompt failed: %s", promptErr) + return nil + } + + if openWeb { + b := browser.New("", cfg.Out, cfg.Err) + if err := b.Browse(prURL); err != nil { + cfg.Warningf("failed to open browser: %s", err) + } else { + cfg.Successf("Opened %s in your browser", prLink) + return nil + } + } + } + + cfg.Printf(" You can merge this PR at: %s", prURL) + return nil +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go new file mode 100644 index 0000000..e065ebe --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,308 @@ +package cmd + +import ( + "io" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +func newMergeMock(tmpDir, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + } +} + +func TestMerge_NoPullRequest(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + 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, ErrSilent) + assert.Contains(t, output, "no pull request found") + assert.Contains(t, output, "gh stack push") +} + +func TestMerge_AlreadyMerged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + Merged: true, + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(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, "already been merged") + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_FullyMergedStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 10, + URL: "https://github.com/owner/repo/pull/10", + Merged: true, + }}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ + Number: 11, + URL: "https://github.com/owner/repo/pull/11", + Merged: true, + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // On trunk with all PRs merged → fully merged message. + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(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, "All PRs in this stack have already been merged") +} + +func TestMerge_OnTrunk(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // Current branch is trunk, not a stack branch. + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + 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, "not a stack branch") +} + +func TestMerge_NonInteractive_PrintsURL(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + // NewTestConfig is non-interactive (piped output), so no confirm prompt. + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(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, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_NoArgs(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"extra-arg", "another"}) + err := cmd.Execute() + + // MaximumNArgs(1) should reject two positional arguments. + assert.Error(t, err) +} + +func TestMerge_ByPRNumber(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{ + Number: 43, + URL: "https://github.com/owner/repo/pull/43", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // Current branch is feat-2, but we target PR #42 (feat-1) via arg. + restore := git.SetOps(newMergeMock(tmpDir, "feat-2")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"42"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_ByPRURL(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "feat-1")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"https://github.com/owner/repo/pull/42"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} + +func TestMerge_ByBranchName(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{ + Number: 42, + URL: "https://github.com/owner/repo/pull/42", + }}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(newMergeMock(tmpDir, "main")) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := MergeCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"feat-1"}) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.NoError(t, err) + assert.Contains(t, output, "https://github.com/owner/repo/pull/42") +} diff --git a/cmd/navigate.go b/cmd/navigate.go new file mode 100644 index 0000000..aad9c7b --- /dev/null +++ b/cmd/navigate.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "strconv" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/spf13/cobra" +) + +func UpCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "up [n]", + Short: "Check out a branch further up in the stack (further from the trunk)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + n := 1 + if len(args) > 0 { + var err error + n, err = strconv.Atoi(args[0]) + if err != nil { + cfg.Errorf("invalid number %q", args[0]) + return ErrInvalidArgs + } + } + return runNavigate(cfg, n) + }, + } +} + +func DownCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "down [n]", + Short: "Check out a branch further down in the stack (closer to the trunk)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + n := 1 + if len(args) > 0 { + var err error + n, err = strconv.Atoi(args[0]) + if err != nil { + cfg.Errorf("invalid number %q", args[0]) + return ErrInvalidArgs + } + } + return runNavigate(cfg, -n) + }, + } +} + +func TopCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "top", + Short: "Check out the top branch of the stack (furthest from the trunk)", + RunE: func(cmd *cobra.Command, args []string) error { + return runNavigateToEnd(cfg, true) + }, + } +} + +func BottomCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "bottom", + Short: "Check out the bottom branch of the stack (closest to the trunk)", + RunE: func(cmd *cobra.Command, args []string) error { + return runNavigateToEnd(cfg, false) + }, + } +} + +func runNavigate(cfg *config.Config, delta int) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + s := result.Stack + currentBranch := result.CurrentBranch + + idx := s.IndexOf(currentBranch) + if idx < 0 { + // Current branch is the trunk (not in s.Branches). + // loadStack guarantees the branch is part of the stack. + if delta > 0 && len(s.Branches) > 0 { + targetIdx := s.FirstActiveBranchIndex() + if targetIdx < 0 { + targetIdx = len(s.Branches) - 1 + cfg.Warningf("Warning: all branches in this stack have been merged") + } + target := s.Branches[targetIdx].Branch + if err := git.CheckoutBranch(target); err != nil { + return err + } + cfg.Successf("Switched to %s", target) + return nil + } + cfg.Printf("Already at the bottom of the stack") + return nil + } + + onMerged := s.Branches[idx].IsMerged() + if onMerged { + cfg.Warningf("Warning: you are on merged branch %q", currentBranch) + } + + var newIdx int + var skipped int + + if onMerged { + // Navigate relative to current position among ALL branches + newIdx = idx + delta + if newIdx < 0 { + newIdx = 0 + } + if newIdx >= len(s.Branches) { + newIdx = len(s.Branches) - 1 + } + } else { + // Build list of active (non-merged) branch indices + activeIndices := s.ActiveBranchIndices() + + // Find current position in active list + activePos := -1 + for i, ai := range activeIndices { + if ai == idx { + activePos = i + break + } + } + + newActivePos := activePos + delta + if newActivePos < 0 { + newActivePos = 0 + } + if newActivePos >= len(activeIndices) { + newActivePos = len(activeIndices) - 1 + } + + newIdx = activeIndices[newActivePos] + + // Count how many merged branches were skipped + if newIdx > idx { + for i := idx + 1; i < newIdx; i++ { + if s.Branches[i].IsMerged() { + skipped++ + } + } + } else if newIdx < idx { + for i := newIdx + 1; i < idx; i++ { + if s.Branches[i].IsMerged() { + skipped++ + } + } + } + } + + if newIdx == idx { + if delta > 0 { + cfg.Printf("Already at the top of the stack") + } else { + cfg.Printf("Already at the bottom of the stack") + } + return nil + } + + target := s.Branches[newIdx].Branch + if err := git.CheckoutBranch(target); err != nil { + return err + } + + if skipped > 0 { + cfg.Printf("Skipped %d merged %s", skipped, plural(skipped, "branch", "branches")) + } + + moved := newIdx - idx + direction := "up" + if moved < 0 { + direction = "down" + moved = -moved + } + + cfg.Successf("Checked out %s, %d %s %s", target, moved, plural(moved, "branch", "branches"), direction) + return nil +} + +func runNavigateToEnd(cfg *config.Config, top bool) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + s := result.Stack + currentBranch := result.CurrentBranch + + if len(s.Branches) == 0 { + cfg.Errorf("stack has no branches") + return ErrNotInStack + } + + var targetIdx int + if top { + targetIdx = len(s.Branches) - 1 + } else { + targetIdx = s.FirstActiveBranchIndex() + if targetIdx < 0 { + // All merged — fall back to first branch with warning + targetIdx = 0 + cfg.Warningf("Warning: all branches in this stack have been merged") + } + } + + target := s.Branches[targetIdx].Branch + if target == currentBranch { + if top { + cfg.Printf("Already at the top of the stack") + } else { + cfg.Printf("Already at the bottom of the stack") + } + return nil + } + + if err := git.CheckoutBranch(target); err != nil { + return err + } + + if s.Branches[targetIdx].IsMerged() { + cfg.Warningf("Warning: you are on merged branch %q", target) + } + + cfg.Successf("Switched to %s", target) + return nil +} + +func plural(n int, singular, pluralForm string) string { + if n == 1 { + return singular + } + return pluralForm +} diff --git a/cmd/navigate_test.go b/cmd/navigate_test.go new file mode 100644 index 0000000..90feefb --- /dev/null +++ b/cmd/navigate_test.go @@ -0,0 +1,415 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readCfgOutput closes cfg writers and reads all captured output. +func readCfgOutput(cfg *config.Config, outR, errR *os.File) string { + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + return string(out) + string(errOut) +} + +func TestNavigate_UpOne(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut) +} + +func TestNavigate_UpN(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetArgs([]string{"2"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut) +} + +func TestNavigate_DownOne(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b3", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := DownCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut) +} + +func TestNavigate_AtTopClamps(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout any branch") + assert.Contains(t, output, "Already at the top") +} + +func TestNavigate_AtBottomClamps(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := DownCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout any branch") + assert.Contains(t, output, "Already at the bottom") +} + +func TestNavigate_FromTrunkGoesUp(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, checkedOut) +} + +func TestNavigate_SkipsMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + {Branch: "b3"}, + }, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + out, _ := io.ReadAll(outR) + errOut, _ := io.ReadAll(errR) + output := string(out) + string(errOut) + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut, "should skip merged b2") + assert.Contains(t, output, "Skipped") +} + +func TestNavigate_Top(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := TopCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b3"}, checkedOut) +} + +func TestNavigate_Bottom(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}, {Branch: "b3"}}, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b3", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := BottomCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b1"}, checkedOut) +} + +func TestNavigate_BottomWithMergedFirst(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b3", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := BottomCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.Equal(t, []string{"b2"}, checkedOut, "should skip merged b1") +} + +func TestNavigate_AllMerged_Up(t *testing.T) { + s := stack.Stack{ + 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}}, + }, + } + + var checkedOut []string + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + CheckoutBranchFn: func(name string) error { + checkedOut = append(checkedOut, name) + return nil + }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + cmd := UpCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + output := readCfgOutput(cfg, outR, errR) + + assert.NoError(t, err) + assert.Empty(t, checkedOut, "should not checkout when already at top of all-merged stack") + assert.Contains(t, output, "Already at the top") + // On a merged branch, navigate prints a warning before the at-top message + assert.Contains(t, output, "you are on merged branch") +} + +// writeStackFile is a helper to write a stack file to a temp dir. +func writeStackFile(t *testing.T, dir string, s stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s}, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644)) +} diff --git a/cmd/push.go b/cmd/push.go new file mode 100644 index 0000000..eb08f46 --- /dev/null +++ b/cmd/push.go @@ -0,0 +1,264 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "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/stack" + "github.com/spf13/cobra" +) + +type pushOptions struct { + auto bool + draft bool + skipPRs bool + remote string +} + +func PushCmd(cfg *config.Config) *cobra.Command { + opts := &pushOptions{} + + cmd := &cobra.Command{ + Use: "push", + Short: "Push all branches in the current stack and create/update PRs", + RunE: func(cmd *cobra.Command, args []string) error { + return runPush(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.auto, "auto", false, "Use auto-generated PR titles without prompting") + cmd.Flags().BoolVar(&opts.draft, "draft", false, "Create PRs as drafts") + cmd.Flags().BoolVar(&opts.skipPRs, "skip-prs", false, "Push branches without creating or updating PRs") + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to push to (defaults to auto-detected remote)") + + return cmd +} + +func runPush(cfg *config.Config, opts *pushOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return ErrNotInStack + } + + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return ErrNotInStack + } + + // Find the stack for the current branch without switching branches. + // Push should never change the user's checked-out branch. + stacks := sf.FindAllStacksForBranch(currentBranch) + if len(stacks) == 0 { + cfg.Errorf("current branch %q is not part of a stack", currentBranch) + return ErrNotInStack + } + if len(stacks) > 1 { + cfg.Errorf("branch %q belongs to multiple stacks; checkout a non-trunk branch first", currentBranch) + return ErrDisambiguate + } + s := stacks[0] + + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure + } + + // Push all active branches atomically + remote, err := pickRemote(cfg, currentBranch, opts.remote) + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return ErrSilent + } + merged := s.MergedBranches() + if len(merged) > 0 { + cfg.Printf("Skipping %d merged %s", len(merged), plural(len(merged), "branch", "branches")) + } + activeBranches := activeBranchNames(s) + cfg.Printf("Pushing %d %s to %s...", len(activeBranches), plural(len(activeBranches), "branch", "branches"), remote) + if err := git.Push(remote, activeBranches, true, true); err != nil { + cfg.Errorf("failed to push: %s", err) + return ErrSilent + } + + if opts.skipPRs { + cfg.Successf("Pushed %d branches (PR creation skipped)", len(s.ActiveBranches())) + return nil + } + + // Create or update PRs + for i, b := range s.Branches { + if s.Branches[i].IsMerged() { + continue + } + baseBranch := s.ActiveBaseBranch(b.Branch) + + pr, err := client.FindPRForBranch(b.Branch) + if err != nil { + cfg.Warningf("failed to check PR for %s: %v", b.Branch, err) + continue + } + + if pr == nil { + // Create new PR — auto-generate title from commits/branch name, + // then prompt interactively unless --auto or non-interactive. + baseBranchForDiff := s.ActiveBaseBranch(b.Branch) + title, commitBody := defaultPRTitleBody(baseBranchForDiff, b.Branch) + originalTitle := title + if !opts.auto && cfg.IsInteractive() { + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + input, err := p.Input(fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return ErrSilent + } + // Non-interrupt error: keep the auto-generated title. + } else if input != "" { + title = input + } + } + + // If the user changed the title and the commit had a multi-line + // message, put the full commit message in the PR body so no + // content is lost. + prBody := commitBody + if title != originalTitle && commitBody != "" { + prBody = originalTitle + "\n\n" + commitBody + } + body := generatePRBody(prBody) + + newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, opts.draft) + if createErr != nil { + cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) + continue + } + cfg.Successf("Created PR %s for %s", cfg.PRLink(newPR.Number, newPR.URL), b.Branch) + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: newPR.Number, + ID: newPR.ID, + URL: newPR.URL, + } + } else { + cfg.Printf("PR %s for %s is up to date", cfg.PRLink(pr.Number, pr.URL), b.Branch) + if s.Branches[i].PullRequest == nil { + s.Branches[i].PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + } + } + } + } + + // TODO: Add PRs to a stack + // + // We can call an API after all the individual PRs are created/updated to create the stack at once, + // or we can add a flag to the existing PR API to incrementally build the stack. + // + // For now, the PRs are pushed and created individually but are NOT linked as a formal stack on GitHub. + cfg.Warningf("Stacked PRs is not yet implemented — PRs were created individually.") + fmt.Fprintf(cfg.Err, " Once the GitHub Stacks API is available, PRs will be automatically\n") + fmt.Fprintf(cfg.Err, " grouped into a Stack.\n") + + // Update base commit hashes and sync PR state + updateBaseSHAs(s) + syncStackPRs(cfg, s) + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return ErrSilent + } + + cfg.Successf("Pushed and synced %d branches", len(s.ActiveBranches())) + return nil +} + +// defaultPRTitleBody generates a PR title and body from the branch's commits. +// If there is exactly one commit, use its subject as the title and its body +// (if any) as the PR body. Otherwise, humanize the branch name for the title. +func defaultPRTitleBody(base, head string) (string, string) { + commits, err := git.LogRange(base, head) + if err == nil && len(commits) == 1 { + return commits[0].Subject, strings.TrimSpace(commits[0].Body) + } + return humanize(head), "" +} + +// generatePRBody builds a PR description from the commit body (if any) +// and a footer linking to the CLI and feedback form. +func generatePRBody(commitBody string) string { + var parts []string + + if commitBody != "" { + parts = append(parts, commitBody) + } + + footer := fmt.Sprintf( + "Stack created with GitHub Stacks CLIGive Feedback 💬", + feedbackBaseURL, + ) + parts = append(parts, footer) + + return strings.Join(parts, "\n\n---\n\n") +} + +// humanize replaces hyphens and underscores with spaces. +func humanize(s string) string { + return strings.Map(func(r rune) rune { + if r == '-' || r == '_' { + return ' ' + } + return r + }, s) +} + +// pickRemote determines which remote to push to. If remoteOverride is +// non-empty, it is returned directly. Otherwise it delegates to +// git.ResolveRemote for config-based resolution and remote listing. +// If multiple remotes exist with no configured default, the user is +// prompted to select one interactively. +func pickRemote(cfg *config.Config, branch, remoteOverride string) (string, error) { + if remoteOverride != "" { + return remoteOverride, nil + } + + remote, err := git.ResolveRemote(branch) + if err == nil { + return remote, nil + } + + var multi *git.ErrMultipleRemotes + if !errors.As(err, &multi) { + return "", err + } + + if !cfg.IsInteractive() { + return "", fmt.Errorf("multiple remotes configured; set remote.pushDefault or use an interactive terminal") + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selected, promptErr := p.Select("Multiple remotes found. Which remote should be used?", "", multi.Remotes) + if promptErr != nil { + if isInterruptError(promptErr) { + printInterrupt(cfg) + return "", errInterrupt + } + return "", fmt.Errorf("remote selection: %w", promptErr) + } + return multi.Remotes[selected], nil +} diff --git a/cmd/push_test.go b/cmd/push_test.go new file mode 100644 index 0000000..8a38a8d --- /dev/null +++ b/cmd/push_test.go @@ -0,0 +1,227 @@ +package cmd + +import ( + "fmt" + "io" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeneratePRBody(t *testing.T) { + tests := []struct { + name string + commitBody string + wantContains []string + }{ + { + name: "empty commit body", + commitBody: "", + wantContains: []string{ + "GitHub Stacks CLI", + feedbackBaseURL, + "", + }, + }, + { + name: "with commit body", + commitBody: "This is a detailed description\nof the change.", + wantContains: []string{ + "This is a detailed description\nof the change.", + "GitHub Stacks CLI", + "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generatePRBody(tt.commitBody) + for _, want := range tt.wantContains { + assert.Contains(t, got, want) + } + }) + } +} + +// newPushMock creates a MockOps pre-configured for push tests. +func newPushMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + ResolveRemoteFn: func(string) (string, error) { return "origin", nil }, + PushFn: func(string, []string, bool, bool) error { return nil }, + } +} + +func TestPush_SkipPRs(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) + + var pushCalls []pushCall + + mock := newPushMock(tmpDir, "b1") + 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 = &github.MockClient{} + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.Equal(t, "origin", pushCalls[0].remote) + assert.Equal(t, []string{"b1", "b2"}, pushCalls[0].branches) + assert.True(t, pushCalls[0].force) + assert.True(t, pushCalls[0].atomic) +} + +func TestPush_SkipsMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3", PullRequest: &stack.PullRequestRef{Number: 3, Merged: true}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var pushCalls []pushCall + + mock := newPushMock(tmpDir, "b2") + 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 = &github.MockClient{} + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + _, _ = io.ReadAll(errR) + + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.Equal(t, []string{"b2"}, pushCalls[0].branches) +} + +func TestPush_PushFailure(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newPushMock(tmpDir, "b1") + mock.PushFn = func(string, []string, bool, bool) error { + return fmt.Errorf("remote rejected") + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + cmd := PushCmd(cfg) + cmd.SetArgs([]string{"--skip-prs"}) + 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, ErrSilent) + assert.Contains(t, output, "failed to push") +} + +func TestPush_DefaultPRTitleBody(t *testing.T) { + t.Run("single_commit", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{ + {Subject: "Add login page", Body: "Implements the OAuth flow"}, + }, nil + }, + }) + defer restore() + + title, body := defaultPRTitleBody("main", "feat-login") + assert.Equal(t, "Add login page", title) + assert.Equal(t, "Implements the OAuth flow", body) + }) + + t.Run("multiple_commits", func(t *testing.T) { + restore := git.SetOps(&git.MockOps{ + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { + return []git.CommitInfo{ + {Subject: "First commit"}, + {Subject: "Second commit"}, + }, nil + }, + }) + defer restore() + + title, body := defaultPRTitleBody("main", "my-feature") + assert.Equal(t, "my feature", title) + assert.Equal(t, "", body) + }) +} + +func TestPush_Humanize(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"my-branch", "my branch"}, + {"my_branch", "my branch"}, + {"nobranch", "nobranch"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, humanize(tt.input)) + }) + } +} diff --git a/cmd/rebase.go b/cmd/rebase.go new file mode 100644 index 0000000..406f78d --- /dev/null +++ b/cmd/rebase.go @@ -0,0 +1,601 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type rebaseOptions struct { + branch string + downstack bool + upstack bool + cont bool + abort bool + remote string +} + +type rebaseState struct { + CurrentBranchIndex int `json:"currentBranchIndex"` + ConflictBranch string `json:"conflictBranch"` + RemainingBranches []string `json:"remainingBranches"` + OriginalBranch string `json:"originalBranch"` + OriginalRefs map[string]string `json:"originalRefs"` + UseOnto bool `json:"useOnto,omitempty"` + OntoOldBase string `json:"ontoOldBase,omitempty"` +} + +const rebaseStateFile = "gh-stack-rebase-state" + +func RebaseCmd(cfg *config.Config) *cobra.Command { + opts := &rebaseOptions{} + + cmd := &cobra.Command{ + Use: "rebase [branch]", + Short: "Rebase a stack of branches", + Long: `Pull from remote and do a cascading rebase across the stack. + +Ensures that each branch in the stack has the tip of the previous +layer in its commit history, rebasing if necessary.`, + Example: ` $ gh stack rebase + $ gh stack rebase --downstack + $ gh stack rebase --continue + $ gh stack rebase --abort`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.branch = args[0] + } + return runRebase(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only rebase branches from trunk to current branch") + cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only rebase branches from current branch to top") + cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts") + cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches") + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from (defaults to auto-detected remote)") + + return cmd +} + +func runRebase(cfg *config.Config, opts *rebaseOptions) error { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return ErrNotInStack + } + + if opts.cont { + return continueRebase(cfg, gitDir) + } + + if opts.abort { + return abortRebase(cfg, gitDir) + } + + result, err := loadStack(cfg, opts.branch) + if err != nil { + return ErrNotInStack + } + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + // Enable git rerere so conflict resolutions are remembered. + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return ErrSilent + } + + // Resolve remote for fetch and trunk comparison + remote, err := pickRemote(cfg, currentBranch, opts.remote) + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return ErrSilent + } + + if err := git.Fetch(remote); err != nil { + cfg.Warningf("Failed to fetch %s: %v", remote, err) + } else { + cfg.Successf("Fetched %s", remote) + } + + // Fast-forward trunk so the cascade rebase targets the latest upstream. + trunk := s.Trunk.Branch + localSHA, remoteSHA := "", "" + trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) + if trunkErr == nil { + localSHA, remoteSHA = trunkRefs[0], trunkRefs[1] + } + + if trunkErr == nil && localSHA != remoteSHA { + isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil { + cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) + } else if !isAncestor { + cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote) + } else if currentBranch == trunk { + if err := git.MergeFF(remote + "/" + trunk); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + } + } else { + if err := updateBranchRef(trunk, remoteSHA); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + } + } + } + + cfg.Printf("Stack detected: %s", s.DisplayChain()) + + currentIdx := s.IndexOf(currentBranch) + if currentIdx < 0 { + currentIdx = 0 + } + + if opts.upstack && currentIdx >= 0 && s.Branches[currentIdx].IsMerged() { + cfg.Warningf("Current branch %q has already been merged", currentBranch) + } + + startIdx := 0 + endIdx := len(s.Branches) + + if opts.downstack { + endIdx = currentIdx + 1 + } + if opts.upstack { + startIdx = currentIdx + } + + branchesToRebase := s.Branches[startIdx:endIdx] + + if len(branchesToRebase) == 0 { + cfg.Printf("No branches to rebase") + return nil + } + + cfg.Printf("Rebasing branches in order, starting from %s to %s", + branchesToRebase[0].Branch, branchesToRebase[len(branchesToRebase)-1].Branch) + + // Sync PR state before rebase so we can detect merged PRs. + syncStackPRs(cfg, s) + + branchNames := make([]string, len(s.Branches)) + for i, b := range s.Branches { + branchNames[i] = b.Branch + } + originalRefs, err := git.RevParseMap(branchNames) + if err != nil { + cfg.Errorf("failed to resolve branch SHAs: %s", err) + return ErrSilent + } + + // Track --onto rebase state for squash-merged branches. + needsOnto := false + var ontoOldBase string + + for i, br := range branchesToRebase { + var base string + absIdx := startIdx + i + if absIdx == 0 { + base = s.Trunk.Branch + } else { + base = s.Branches[absIdx-1].Branch + } + + // Skip branches whose PRs have already been merged (e.g. via squash). + // Record state so subsequent branches can use --onto rebase. + if br.IsMerged() { + ontoOldBase = originalRefs[br.Branch] + needsOnto = true + cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) + continue + } + + if needsOnto { + // Find the proper --onto target: the first non-merged ancestor, or trunk. + newBase := s.Trunk.Branch + for j := absIdx - 1; j >= 0; j-- { + b := s.Branches[j] + if !b.IsMerged() { + newBase = b.Branch + break + } + } + + if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, newBase) + + remaining := make([]string, 0) + for j := i + 1; j < len(branchesToRebase); j++ { + remaining = append(remaining, branchesToRebase[j].Branch) + } + + state := &rebaseState{ + CurrentBranchIndex: absIdx, + ConflictBranch: br.Branch, + RemainingBranches: remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + UseOnto: true, + OntoOldBase: originalRefs[br.Branch], + } + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } + + printConflictDetails(cfg, newBase) + cfg.Printf("") + + cfg.Printf("Resolve conflicts on %s, then run `%s`", + br.Branch, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with `%s`", + cfg.ColorCyan("gh stack rebase --abort")) + return ErrConflict + } + + cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) + // Keep --onto mode; update old base for the next branch. + ontoOldBase = originalRefs[br.Branch] + } else { + var rebaseErr error + if absIdx > 0 { + // Use --onto to replay only this branch's unique commits. + // Without --onto, git may try to replay commits shared with + // the parent, causing duplicate-patch conflicts when the + // parent's rebase rewrote those commits. + rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch) + } else { + if err := git.CheckoutBranch(br.Branch); err != nil { + return fmt.Errorf("checking out %s: %w", br.Branch, err) + } + // Use regular rebase for the first branch. + rebaseErr = git.Rebase(base) + } + + if rebaseErr != nil { + cfg.Warningf("Rebasing %s onto %s — conflict", br.Branch, base) + + remaining := make([]string, 0) + for j := i + 1; j < len(branchesToRebase); j++ { + remaining = append(remaining, branchesToRebase[j].Branch) + } + + state := &rebaseState{ + CurrentBranchIndex: absIdx, + ConflictBranch: br.Branch, + RemainingBranches: remaining, + OriginalBranch: currentBranch, + OriginalRefs: originalRefs, + } + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } + + printConflictDetails(cfg, base) + cfg.Printf("") + + cfg.Printf("Resolve conflicts on %s, then run `%s`", + br.Branch, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with `%s`", + cfg.ColorCyan("gh stack rebase --abort")) + return ErrConflict + } + + cfg.Successf("Rebased %s onto %s", br.Branch, base) + } + } + + _ = git.CheckoutBranch(currentBranch) + + updateBaseSHAs(s) + + syncStackPRs(cfg, s) + + _ = stack.Save(gitDir, sf) + + merged := s.MergedBranches() + if len(merged) > 0 { + names := make([]string, len(merged)) + for i, m := range merged { + names[i] = m.Branch + } + cfg.Printf("Skipped %d merged %s: %s", len(merged), plural(len(merged), "branch", "branches"), strings.Join(names, ", ")) + } + + rangeDesc := "All branches in stack" + if opts.downstack { + rangeDesc = fmt.Sprintf("All downstack branches up to %s", currentBranch) + } else if opts.upstack { + rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch) + } + + cfg.Printf("%s rebased locally with %s", rangeDesc, s.Trunk.Branch) + cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", + cfg.ColorCyan("gh stack push")) + + return nil +} + +func continueRebase(cfg *config.Config, gitDir string) error { + state, err := loadRebaseState(gitDir) + if err != nil { + cfg.Errorf("no rebase in progress") + return ErrSilent + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return ErrNotInStack + } + + // Use the saved original branch to find the stack, since git may be in + // a detached HEAD state during an active rebase. + s, err := resolveStack(sf, state.OriginalBranch, cfg) + if err != nil { + return err + } + if s == nil { + return fmt.Errorf("no stack found for branch %s", state.OriginalBranch) + } + + // 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 + if conflictBranch == "" && state.CurrentBranchIndex >= 0 && state.CurrentBranchIndex < len(s.Branches) { + conflictBranch = s.Branches[state.CurrentBranchIndex].Branch + } + + cfg.Printf("Continuing rebase of stack, resuming from %s to %s", + conflictBranch, s.Branches[len(s.Branches)-1].Branch) + + if git.IsRebaseInProgress() { + if err := git.RebaseContinue(); err != nil { + return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err) + } + } + + var baseBranch string + if state.UseOnto { + // 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].IsMerged() { + baseBranch = s.Branches[j].Branch + break + } + } + } else if state.CurrentBranchIndex > 0 { + baseBranch = s.Branches[state.CurrentBranchIndex-1].Branch + } else { + baseBranch = s.Trunk.Branch + } + cfg.Successf("Rebased %s onto %s", conflictBranch, baseBranch) + + for _, branchName := range state.RemainingBranches { + idx := s.IndexOf(branchName) + if idx < 0 { + return fmt.Errorf("branch %q from saved rebase state is no longer in the stack — the stack may have been modified since the rebase started; consider aborting with --abort", branchName) + } + + // Skip branches whose PRs have already been merged. + br := s.Branches[idx] + if br.IsMerged() { + state.OntoOldBase = state.OriginalRefs[branchName] + state.UseOnto = true + cfg.Successf("Skipping %s (PR %s merged)", branchName, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) + continue + } + + var base string + if idx == 0 { + base = s.Trunk.Branch + } else { + base = s.Branches[idx-1].Branch + } + + if state.UseOnto { + // Find the proper --onto target: first non-merged ancestor, or trunk. + newBase := s.Trunk.Branch + for j := idx - 1; j >= 0; j-- { + b := s.Branches[j] + if !b.IsMerged() { + newBase = b.Branch + break + } + } + + if err := git.RebaseOnto(newBase, state.OntoOldBase, branchName); err != nil { + remainIdx := -1 + for ri, rb := range state.RemainingBranches { + if rb == branchName { + remainIdx = ri + break + } + } + state.RemainingBranches = state.RemainingBranches[remainIdx+1:] + state.CurrentBranchIndex = idx + state.ConflictBranch = branchName + state.OntoOldBase = state.OriginalRefs[branchName] + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } + + cfg.Warningf("Rebasing %s onto %s — conflict", branchName, newBase) + printConflictDetails(cfg, newBase) + cfg.Printf("") + cfg.Printf("Resolve conflicts on %s, then run `%s`", + branchName, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with `%s`", + cfg.ColorCyan("gh stack rebase --abort")) + return ErrConflict + } + + cfg.Successf("Rebased %s onto %s (squash-merge detected)", branchName, newBase) + state.OntoOldBase = state.OriginalRefs[branchName] + } else { + var rebaseErr error + if idx > 0 { + // Use --onto to replay only this branch's unique commits. + rebaseErr = git.RebaseOnto(base, state.OriginalRefs[base], branchName) + } else { + if err := git.CheckoutBranch(branchName); err != nil { + cfg.Errorf("checking out %s: %s", branchName, err) + return ErrSilent + } + rebaseErr = git.Rebase(base) + } + + if rebaseErr != nil { + remainIdx := -1 + for ri, rb := range state.RemainingBranches { + if rb == branchName { + remainIdx = ri + break + } + } + state.RemainingBranches = state.RemainingBranches[remainIdx+1:] + state.CurrentBranchIndex = idx + state.ConflictBranch = branchName + if err := saveRebaseState(gitDir, state); err != nil { + cfg.Warningf("failed to save rebase state: %s", err) + } + + cfg.Warningf("Rebasing %s onto %s — conflict", branchName, base) + printConflictDetails(cfg, base) + cfg.Printf("") + cfg.Printf("Resolve conflicts on %s, then run `%s`", + branchName, cfg.ColorCyan("gh stack rebase --continue")) + cfg.Printf("Or abort this operation with `%s`", + cfg.ColorCyan("gh stack rebase --abort")) + return ErrConflict + } + + cfg.Successf("Rebased %s onto %s", branchName, base) + } + } + + clearRebaseState(gitDir) + _ = git.CheckoutBranch(state.OriginalBranch) + + updateBaseSHAs(s) + + syncStackPRs(cfg, s) + + _ = stack.Save(gitDir, sf) + + cfg.Printf("All branches in stack rebased locally with %s", s.Trunk.Branch) + cfg.Printf("To push up your changes and open/update the stack of PRs, run `%s`", + cfg.ColorCyan("gh stack push")) + + return nil +} + +func abortRebase(cfg *config.Config, gitDir string) error { + state, err := loadRebaseState(gitDir) + if err != nil { + cfg.Errorf("no rebase in progress") + return ErrSilent + } + + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + + var restoreErrors []string + for branch, sha := range state.OriginalRefs { + if err := git.CheckoutBranch(branch); err != nil { + restoreErrors = append(restoreErrors, fmt.Sprintf("checkout %s: %s", branch, err)) + continue + } + if err := git.ResetHard(sha); err != nil { + restoreErrors = append(restoreErrors, fmt.Sprintf("reset %s: %s", branch, err)) + } + } + + _ = git.CheckoutBranch(state.OriginalBranch) + clearRebaseState(gitDir) + + if len(restoreErrors) > 0 { + cfg.Warningf("Rebase aborted but some branches could not be fully restored:") + for _, e := range restoreErrors { + cfg.Printf(" %s", e) + } + return ErrSilent + } + + cfg.Successf("Rebase aborted and branches restored") + return nil +} + +func saveRebaseState(gitDir string, state *rebaseState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("error serializing rebase state: %w", err) + } + if err := os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644); err != nil { + return fmt.Errorf("error writing rebase state: %w", err) + } + return nil +} + +func loadRebaseState(gitDir string) (*rebaseState, error) { + data, err := os.ReadFile(filepath.Join(gitDir, rebaseStateFile)) + if err != nil { + return nil, err + } + var state rebaseState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func clearRebaseState(gitDir string) { + _ = os.Remove(filepath.Join(gitDir, rebaseStateFile)) +} + +func printConflictDetails(cfg *config.Config, branch string) { + files, err := git.ConflictedFiles() + if err != nil || len(files) == 0 { + return + } + + cfg.Printf("") + cfg.Printf("%s", cfg.ColorBold("Conflicted files:")) + for _, f := range files { + info, err := git.FindConflictMarkers(f) + if err != nil || len(info.Sections) == 0 { + cfg.Printf(" %s %s", cfg.ColorWarning("C"), f) + continue + } + for _, sec := range info.Sections { + cfg.Printf(" %s %s (lines %d–%d)", + cfg.ColorWarning("C"), f, sec.StartLine, sec.EndLine) + } + } + + cfg.Printf("") + cfg.Printf("%s", cfg.ColorBold("To resolve:")) + cfg.Printf(" 1. Open each conflicted file and look for conflict markers:") + cfg.Printf(" %s (incoming changes from %s)", cfg.ColorCyan("<<<<<<< HEAD"), branch) + cfg.Printf(" %s", cfg.ColorCyan("=======")) + cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>")) + cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers") + cfg.Printf(" 3. Stage resolved files: `%s`", cfg.ColorCyan("git add ")) + cfg.Printf(" 4. Continue the rebase: `%s`", cfg.ColorCyan("gh stack rebase --continue")) +} diff --git a/cmd/rebase_test.go b/cmd/rebase_test.go new file mode 100644 index 0000000..1b70db0 --- /dev/null +++ b/cmd/rebase_test.go @@ -0,0 +1,850 @@ +package cmd + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rebaseCall records arguments passed to RebaseOnto or Rebase. +type rebaseCall struct { + newBase string + oldBase string + branch string +} + +// resetCall records arguments passed to CheckoutBranch + ResetHard. +type resetCall struct { + branch string + sha string +} + +// newRebaseMock creates a MockOps pre-configured for rebase tests. +// It returns stable SHAs based on ref name, tracks checkout, and allows +// callers to override specific function fields after creation. +func newRebaseMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + RevParseFn: func(ref string) (string, error) { 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 }, + IsRebaseInProgressFn: func() bool { return false }, + } +} + +// TestRebase_CascadeRebase verifies that a stack [b1, b2, b3] with all active +// branches triggers the correct cascade: b1 rebased onto trunk, b2 onto b1, +// b3 onto b2. +func TestRebase_CascadeRebase(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var allRebaseCalls []rebaseCall + var currentCheckedOut string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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) + + // All branches should be rebased in order: b1 onto main, b2 onto b1, b3 onto b2 + require.Len(t, allRebaseCalls, 3) + assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") + assert.Equal(t, "b2", allRebaseCalls[2].newBase, "b3 should be rebased onto b2") + + assert.Contains(t, output, "rebased locally") +} + +// TestRebase_SquashMergedBranch_UsesOnto verifies that when b1 has a merged PR, +// it is skipped and b2 uses RebaseOnto with trunk as newBase and b1's original +// SHA as oldBase. b3 also uses --onto (propagation). +func TestRebase_SquashMergedBranch_UsesOnto(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"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "main": "main-sha-aaa", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + } + + mock := newRebaseMock(tmpDir, "b2") + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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") + + // b2: onto trunk, oldBase = b1's original SHA + // b3: onto b2, oldBase = b2's original SHA (propagation) + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseCalls[0], + "b2 should rebase --onto main using b1's original SHA as oldBase") + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[1], + "b3 should propagate --onto mode with b2's original SHA as oldBase") +} + +// TestRebase_OntoPropagatesToSubsequentBranches verifies that when multiple +// branches are squash-merged, --onto propagates correctly through the chain. +func TestRebase_OntoPropagatesToSubsequentBranches(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, Merged: true}}, + {Branch: "b3"}, + {Branch: "b4"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "main": "main-sha-aaa", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + "b4": "b4-orig-sha", + } + + mock := newRebaseMock(tmpDir, "b3") + mock.RevParseFn = func(ref string) (string, error) { + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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, "Skipping b2") + + // b1 merged → ontoOldBase = b1-orig-sha + // b2 merged → ontoOldBase = b2-orig-sha + // b3: first non-merged ancestor search finds none → newBase = trunk + // RebaseOnto("main", "b2-orig-sha", "b3") + // b4: first non-merged ancestor = b3 → newBase = b3 + // RebaseOnto("b3", "b3-orig-sha", "b4") + require.Len(t, rebaseCalls, 2) + assert.Equal(t, rebaseCall{"main", "b2-orig-sha", "b3"}, rebaseCalls[0], + "b3 should rebase --onto main with b2's SHA as oldBase") + assert.Equal(t, rebaseCall{"b3", "b3-orig-sha", "b4"}, rebaseCalls[1], + "b4 should rebase --onto b3 with b3's original SHA as oldBase") +} + +// TestRebase_ConflictSavesState verifies that when a rebase conflict occurs, +// the state is saved with the conflict branch and remaining branches. +func TestRebase_ConflictSavesState(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + mock := newRebaseMock(tmpDir, "b1") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } // b1 succeeds + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b2" { + return assert.AnError // conflict on b2 + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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.Error(t, err) + assert.ErrorIs(t, err, ErrConflict) + assert.Contains(t, output, "--continue") + + // Verify state file was saved + stateData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state")) + require.NoError(t, readErr, "rebase state file should be saved") + + var state rebaseState + require.NoError(t, json.Unmarshal(stateData, &state)) + assert.Equal(t, "b2", state.ConflictBranch) + assert.Equal(t, []string{"b3"}, state.RemainingBranches) + assert.Equal(t, "b1", state.OriginalBranch) + assert.Contains(t, state.OriginalRefs, "b1") + assert.Contains(t, state.OriginalRefs, "b2") + assert.Contains(t, state.OriginalRefs, "b3") +} + +// TestRebase_Continue_NoState verifies that --continue without a state file +// produces a "no rebase in progress" message. +func TestRebase_Continue_NoState(t *testing.T) { + tmpDir := t.TempDir() + + mock := newRebaseMock(tmpDir, "b1") + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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.ErrorIs(t, err, ErrSilent) + assert.Contains(t, output, "no rebase in progress") +} + +// TestRebase_Abort_RestoresBranches verifies that --abort restores all branches +// to their original SHAs and removes the state file. +func TestRebase_Abort_RestoresBranches(t *testing.T) { + tmpDir := t.TempDir() + + // Pre-create rebase state + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "b1": "orig-sha-b1", + "b2": "orig-sha-b2", + "b3": "orig-sha-b3", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var resets []resetCall + var checkouts []string + currentBranch := "b2" // simulating we're on the conflict branch + + mock := newRebaseMock(tmpDir, currentBranch) + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--abort"}) + 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, "Rebase aborted and branches restored") + + // Verify each branch was reset to its original SHA. + // Map iteration order is non-deterministic, so collect into a map. + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "orig-sha-b1", resetMap["b1"]) + assert.Equal(t, "orig-sha-b2", resetMap["b2"]) + assert.Equal(t, "orig-sha-b3", resetMap["b3"]) + + // State file should be removed + _, err = os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(err), "state file should be removed after abort") + + // Should return to original branch + assert.Contains(t, checkouts, "b1", "should checkout original branch at end") +} + +// TestRebase_DownstackOnly verifies that --downstack only rebases branches +// from trunk to the current branch (inclusive). +func TestRebase_DownstackOnly(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var allRebaseCalls []rebaseCall + var currentCheckedOut string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--downstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + // b2 is at index 1, so downstack = [b1, b2] (indices 0..1) + require.Len(t, allRebaseCalls, 2, "downstack should rebase b1 and b2 only") + assert.Equal(t, "main", allRebaseCalls[0].newBase, "b1 should be rebased onto trunk") + assert.Equal(t, "b1", allRebaseCalls[1].newBase, "b2 should be rebased onto b1") +} + +// TestRebase_UpstackOnly verifies that --upstack only rebases branches +// from the current branch to the top. +func TestRebase_UpstackOnly(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var allRebaseCalls []rebaseCall + var currentCheckedOut string + + mock := newRebaseMock(tmpDir, "b2") + mock.CheckoutBranchFn = func(name string) error { + currentCheckedOut = name + return nil + } + mock.RebaseFn = func(base string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase: base, oldBase: "", branch: currentCheckedOut}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + allRebaseCalls = append(allRebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--upstack"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + // b2 is at index 1, upstack = [b2, b3] (indices 1..2) + require.Len(t, allRebaseCalls, 2, "upstack should rebase b2 and b3") + assert.Equal(t, "b1", allRebaseCalls[0].newBase, "b2 should be rebased onto b1") + assert.Equal(t, "b2", allRebaseCalls[1].newBase, "b3 should be rebased onto b2") +} + +// TestRebase_SkipsMergedBranches verifies that merged branches are skipped +// with an appropriate message. +func TestRebase_SkipsMergedBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 42, Merged: true}}, + {Branch: "b2"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + + mock := newRebaseMock(tmpDir, "b2") + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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 #42 merged") + + // Only b2 should be rebased + require.Len(t, rebaseCalls, 1) + assert.Equal(t, "b2", rebaseCalls[0].branch) +} + +// 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) { + tmpDir := t.TempDir() + + original := &rebaseState{ + CurrentBranchIndex: 2, + ConflictBranch: "feature-b", + RemainingBranches: []string{"feature-c", "feature-d"}, + OriginalBranch: "feature-a", + OriginalRefs: map[string]string{ + "feature-a": "aaa111", + "feature-b": "bbb222", + "feature-c": "ccc333", + "feature-d": "ddd444", + }, + UseOnto: true, + OntoOldBase: "bbb222", + } + + err := saveRebaseState(tmpDir, original) + require.NoError(t, err) + + loaded, err := loadRebaseState(tmpDir) + require.NoError(t, err) + + assert.Equal(t, original.CurrentBranchIndex, loaded.CurrentBranchIndex) + assert.Equal(t, original.ConflictBranch, loaded.ConflictBranch) + assert.Equal(t, original.RemainingBranches, loaded.RemainingBranches) + assert.Equal(t, original.OriginalBranch, loaded.OriginalBranch) + assert.Equal(t, original.OriginalRefs, loaded.OriginalRefs) + assert.Equal(t, original.UseOnto, loaded.UseOnto) + assert.Equal(t, original.OntoOldBase, loaded.OntoOldBase) +} + +// TestRebase_Continue_RebasesRemainingBranches verifies the --continue success +// path: RebaseContinue is called, remaining branches are rebased via RebaseOnto, +// the state file is cleaned up, and the original branch is restored. +func TestRebase_Continue_RebasesRemainingBranches(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // State: b2 had a conflict (index 1), b3 remains to be rebased. + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "main-orig-sha", + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseContinueCalled bool + var rebaseCalls []rebaseCall + var checkouts []string + + mock := newRebaseMock(tmpDir, "b2") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { + rebaseContinueCalled = true + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.True(t, rebaseContinueCalled, "RebaseContinue should be called") + + // b3 is at idx 2 (idx > 0, not UseOnto) → RebaseOnto(base=b2, originalRefs[b2], b3) + require.Len(t, rebaseCalls, 1) + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseCalls[0]) + + // State file should be removed after success + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after success") + + // Original branch should be checked out at the end + assert.Contains(t, checkouts, "b1", "should checkout original branch") +} + +// TestRebase_Continue_OntoMode verifies the --continue path when UseOnto is +// set (squash-merged branches upstream). With no remaining branches, only +// RebaseContinue runs and the state is cleaned up. +func TestRebase_Continue_OntoMode(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, Merged: true}}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + // b3 was the conflict branch; no remaining branches after it. + state := &rebaseState{ + CurrentBranchIndex: 2, + ConflictBranch: "b3", + RemainingBranches: []string{}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "sha-main", + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + }, + UseOnto: true, + OntoOldBase: "sha-b2", + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseContinueCalled bool + + mock := newRebaseMock(tmpDir, "b3") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { + rebaseContinueCalled = true + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--continue"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + assert.NoError(t, err) + assert.True(t, rebaseContinueCalled, "RebaseContinue should be called") + + // State file should be removed after success + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after success") +} + +// TestRebase_Continue_ConflictOnRemaining verifies that when --continue +// successfully resolves the first conflict but hits a new conflict on a +// remaining branch, the state is updated and ErrConflict is returned. +func TestRebase_Continue_ConflictOnRemaining(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + {Branch: "b4"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{"b3", "b4"}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "main": "sha-main", + "b1": "sha-b1", + "b2": "sha-b2", + "b3": "sha-b3", + "b4": "sha-b4", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + mock := newRebaseMock(tmpDir, "b2") + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseContinueFn = func() error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b3" { + return assert.AnError // conflict on b3 + } + return nil + } + mock.ConflictedFilesFn = func() ([]string, error) { return nil, nil } + mock.CheckoutBranchFn = func(string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + 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.Error(t, err) + assert.ErrorIs(t, err, ErrConflict) + assert.Contains(t, output, "--continue") + + // State file should still exist with updated conflict info + updatedData, readErr := os.ReadFile(filepath.Join(tmpDir, "gh-stack-rebase-state")) + require.NoError(t, readErr, "state file should still exist after new conflict") + + var updatedState rebaseState + require.NoError(t, json.Unmarshal(updatedData, &updatedState)) + assert.Equal(t, "b3", updatedState.ConflictBranch) + assert.Equal(t, []string{"b4"}, updatedState.RemainingBranches) +} + +// TestRebase_Abort_WithActiveRebase verifies that --abort calls RebaseAbort +// when a git rebase is in progress, restores branches, and cleans up the state. +func TestRebase_Abort_WithActiveRebase(t *testing.T) { + tmpDir := t.TempDir() + + state := &rebaseState{ + CurrentBranchIndex: 1, + ConflictBranch: "b2", + RemainingBranches: []string{}, + OriginalBranch: "b1", + OriginalRefs: map[string]string{ + "b1": "orig-sha-b1", + "b2": "orig-sha-b2", + }, + } + stateData, _ := json.MarshalIndent(state, "", " ") + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "gh-stack-rebase-state"), stateData, 0644)) + + var rebaseAbortCalled bool + var resets []resetCall + var checkouts []string + currentBranch := "b2" + + mock := newRebaseMock(tmpDir, currentBranch) + mock.IsRebaseInProgressFn = func() bool { return true } + mock.RebaseAbortFn = func() error { + rebaseAbortCalled = true + return nil + } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := RebaseCmd(cfg) + cmd.SetArgs([]string{"--abort"}) + 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.True(t, rebaseAbortCalled, "RebaseAbort should be called when rebase is in progress") + assert.Contains(t, output, "Rebase aborted and branches restored") + + // Verify branches restored to original SHAs + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "orig-sha-b1", resetMap["b1"]) + assert.Equal(t, "orig-sha-b2", resetMap["b2"]) + + // State file should be removed + _, statErr := os.Stat(filepath.Join(tmpDir, "gh-stack-rebase-state")) + assert.True(t, os.IsNotExist(statErr), "state file should be removed after abort") + + // Should return to original branch + assert.Contains(t, checkouts, "b1", "should checkout original branch at end") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..db872f4 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + + "github.com/github/gh-stack/internal/config" + "github.com/spf13/cobra" +) + +func RootCmd() *cobra.Command { + cfg := config.New() + + root := &cobra.Command{ + Use: "stack ", + Short: "Manage stacked branches and pull requests", + Long: "Create, navigate, and manage stacks of branches and pull requests.", + Version: Version, + SilenceUsage: true, + SilenceErrors: true, + } + + root.SetVersionTemplate("gh stack version {{.Version}}\n") + + root.SetOut(cfg.Out) + root.SetErr(cfg.Err) + + // Local operations + root.AddCommand(InitCmd(cfg)) + root.AddCommand(AddCmd(cfg)) + + // Remote operations + root.AddCommand(CheckoutCmd(cfg)) + root.AddCommand(PushCmd(cfg)) + root.AddCommand(SyncCmd(cfg)) + root.AddCommand(UnstackCmd(cfg)) + root.AddCommand(MergeCmd(cfg)) + + // Helper commands + root.AddCommand(ViewCmd(cfg)) + root.AddCommand(RebaseCmd(cfg)) + + // Navigation commands + root.AddCommand(UpCmd(cfg)) + root.AddCommand(DownCmd(cfg)) + root.AddCommand(TopCmd(cfg)) + root.AddCommand(BottomCmd(cfg)) + + // Feedback + root.AddCommand(FeedbackCmd(cfg)) + + return root +} + +func Execute() { + cmd := RootCmd() + + // Wrap in a "gh" parent so help output shows "gh stack" instead of just "stack". + wrapCmd := &cobra.Command{Use: "gh", SilenceUsage: true, SilenceErrors: true} + wrapCmd.AddCommand(cmd) + wrapCmd.SetArgs(append([]string{"stack"}, os.Args[1:]...)) + + if err := wrapCmd.Execute(); err != nil { + var exitErr *ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.Code) + } + fmt.Fprintln(cmd.ErrOrStderr(), err) + os.Exit(1) + } +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..df3c427 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRootCmd_SubcommandRegistration(t *testing.T) { + root := RootCmd() + expected := []string{"init", "add", "checkout", "push", "sync", "unstack", "merge", "view", "rebase", "up", "down", "top", "bottom", "feedback"} + + registered := make(map[string]bool) + for _, cmd := range root.Commands() { + registered[cmd.Name()] = true + } + + for _, name := range expected { + assert.True(t, registered[name], "expected subcommand %q to be registered", name) + } +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..840f6dd --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,338 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type syncOptions struct { + remote string +} + +func SyncCmd(cfg *config.Config) *cobra.Command { + opts := &syncOptions{} + + cmd := &cobra.Command{ + Use: "sync", + 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: + + 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 + +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.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSync(cfg, opts) + }, + } + + cmd.Flags().StringVar(&opts.remote, "remote", "", "Remote to fetch from and push to (defaults to auto-detected remote)") + + return cmd +} + +func runSync(cfg *config.Config, opts *syncOptions) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + // Resolve remote once for fetch and push + remote, err := pickRemote(cfg, currentBranch, opts.remote) + if err != nil { + if !errors.Is(err, errInterrupt) { + cfg.Errorf("%s", err) + } + return ErrSilent + } + + // --- Step 1: Fetch --- + // Enable git rerere so conflict resolutions are remembered. + if err := ensureRerere(cfg); errors.Is(err, errInterrupt) { + return ErrSilent + } + + if err := git.Fetch(remote); err != nil { + cfg.Warningf("Failed to fetch %s: %v", remote, err) + } else { + cfg.Successf("Fetched latest changes from %s", remote) + } + + // --- Step 2: Fast-forward trunk --- + trunk := s.Trunk.Branch + trunkUpdated := false + + localSHA, remoteSHA := "", "" + trunkRefs, trunkErr := git.RevParseMulti([]string{trunk, remote + "/" + trunk}) + if trunkErr == nil { + localSHA, remoteSHA = trunkRefs[0], trunkRefs[1] + } + + if trunkErr != nil { + cfg.Warningf("Could not compare trunk %s with remote — skipping trunk update", trunk) + } else if localSHA == remoteSHA { + cfg.Successf("Trunk %s is already up to date", trunk) + } else { + isAncestor, err := git.IsAncestor(localSHA, remoteSHA) + if err != nil { + cfg.Warningf("Could not determine fast-forward status for %s: %v", trunk, err) + } else if !isAncestor { + cfg.Warningf("Trunk %s has diverged from %s — skipping trunk update", trunk, remote) + cfg.Printf(" Local and remote %s have diverged. Resolve manually.", trunk) + } else { + // Fast-forward the trunk branch + if currentBranch == trunk { + if err := git.MergeFF(remote + "/" + trunk); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + trunkUpdated = true + } + } else { + if err := updateBranchRef(trunk, remoteSHA); err != nil { + cfg.Warningf("Failed to fast-forward %s: %v", trunk, err) + } else { + cfg.Successf("Trunk %s fast-forwarded to %s", trunk, short(remoteSHA)) + trunkUpdated = true + } + } + } + } + + // --- Step 3: Cascade rebase (only if trunk moved) --- + rebased := false + if trunkUpdated { + cfg.Printf("") + cfg.Printf("Rebasing stack ...") + + // Sync PR state to detect merged PRs before rebasing. + syncStackPRs(cfg, s) + + // Save original refs so we can restore on conflict + branchNames := make([]string, len(s.Branches)) + for i, b := range s.Branches { + branchNames[i] = b.Branch + } + originalRefs, _ := git.RevParseMap(branchNames) + + needsOnto := false + var ontoOldBase string + + conflicted := false + for i, br := range s.Branches { + var base string + if i == 0 { + base = trunk + } else { + base = s.Branches[i-1].Branch + } + + // Skip branches whose PRs have already been merged. + if br.IsMerged() { + ontoOldBase = originalRefs[br.Branch] + needsOnto = true + cfg.Successf("Skipping %s (PR %s merged)", br.Branch, cfg.PRLink(br.PullRequest.Number, br.PullRequest.URL)) + continue + } + + if needsOnto { + // Find --onto target: first non-merged ancestor, or trunk. + newBase := trunk + for j := i - 1; j >= 0; j-- { + b := s.Branches[j] + if !b.IsMerged() { + newBase = b.Branch + break + } + } + + if err := git.RebaseOnto(newBase, ontoOldBase, br.Branch); err != nil { + // Conflict detected — abort and restore everything + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + restoreErrors := restoreBranches(originalRefs) + _ = git.CheckoutBranch(currentBranch) + + cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, newBase) + reportRestoreStatus(cfg, restoreErrors) + cfg.Printf(" Run `%s` to resolve conflicts interactively.", + cfg.ColorCyan("gh stack rebase")) + conflicted = true + break + } + + cfg.Successf("Rebased %s onto %s (squash-merge detected)", br.Branch, newBase) + ontoOldBase = originalRefs[br.Branch] + } else { + var rebaseErr error + if i > 0 { + // Use --onto to replay only this branch's unique commits. + rebaseErr = git.RebaseOnto(base, originalRefs[base], br.Branch) + } else { + if err := git.CheckoutBranch(br.Branch); err != nil { + cfg.Errorf("Failed to checkout %s: %v", br.Branch, err) + conflicted = true + break + } + rebaseErr = git.Rebase(base) + } + + if rebaseErr != nil { + // Conflict detected — abort and restore everything + if git.IsRebaseInProgress() { + _ = git.RebaseAbort() + } + restoreErrors := restoreBranches(originalRefs) + _ = git.CheckoutBranch(currentBranch) + + cfg.Errorf("Conflict detected rebasing %s onto %s", br.Branch, base) + reportRestoreStatus(cfg, restoreErrors) + cfg.Printf(" Run `%s` to resolve conflicts interactively.", + cfg.ColorCyan("gh stack rebase")) + conflicted = true + break + } + + cfg.Successf("Rebased %s onto %s", br.Branch, base) + } + } + + if !conflicted { + rebased = true + _ = git.CheckoutBranch(currentBranch) + } else { + // Persist refreshed PR state even on conflict, then bail out + // before pushing or reporting success. + _ = stack.Save(gitDir, sf) + return ErrConflict + } + } + + // --- Step 4: Push --- + cfg.Printf("") + branches := activeBranchNames(s) + + if mergedCount := len(s.MergedBranches()); mergedCount > 0 { + cfg.Printf("Skipping %d merged %s", mergedCount, plural(mergedCount, "branch", "branches")) + } + + if len(branches) == 0 { + cfg.Printf("No active branches to push (all merged)") + } else { + // After rebase, force-with-lease is required (history rewritten). + // Without rebase, try a normal push first. + force := rebased + cfg.Printf("Pushing %d %s to %s...", len(branches), plural(len(branches), "branch", "branches"), remote) + if err := git.Push(remote, branches, force, true); err != nil { + if !force { + cfg.Warningf("Push failed — branches may need force push after rebase") + cfg.Printf(" Run `%s` to push with --force-with-lease.", + cfg.ColorCyan("gh stack push")) + } else { + cfg.Warningf("Push failed: %v", err) + cfg.Printf(" Run `%s` to retry.", cfg.ColorCyan("gh stack push")) + } + } else { + cfg.Successf("Pushed %d branches", len(branches)) + } + } + + // --- Step 5: Sync PR state --- + cfg.Printf("") + cfg.Printf("Syncing PRs ...") + syncStackPRs(cfg, s) + + // Report PR status for each branch + for _, b := range s.Branches { + if b.IsMerged() { + continue + } + if b.PullRequest != nil { + cfg.Successf("PR %s (%s) — Open", cfg.PRLink(b.PullRequest.Number, b.PullRequest.URL), b.Branch) + } else { + cfg.Warningf("%s has no PR", b.Branch) + } + } + merged := s.MergedBranches() + if len(merged) > 0 { + names := make([]string, len(merged)) + for i, m := range merged { + if m.PullRequest != nil { + names[i] = fmt.Sprintf("#%d", m.PullRequest.Number) + } else { + names[i] = m.Branch + } + } + cfg.Printf("Merged: %s", strings.Join(names, ", ")) + } + + // --- Step 6: Update base SHAs and save --- + updateBaseSHAs(s) + + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return ErrSilent + } + + cfg.Printf("") + cfg.Successf("Stack synced") + return nil +} + +// updateBranchRef updates a branch ref to point to a new SHA (for branches not checked out). +func updateBranchRef(branch, sha string) error { + return git.UpdateBranchRef(branch, sha) +} + +// restoreBranches resets each branch to its original SHA, collecting any errors. +func restoreBranches(originalRefs map[string]string) []string { + var errors []string + for branch, sha := range originalRefs { + if err := git.CheckoutBranch(branch); err != nil { + errors = append(errors, fmt.Sprintf("checkout %s: %s", branch, err)) + continue + } + if err := git.ResetHard(sha); err != nil { + errors = append(errors, fmt.Sprintf("reset %s: %s", branch, err)) + } + } + return errors +} + +// reportRestoreStatus prints whether branch restoration succeeded or partially failed. +func reportRestoreStatus(cfg *config.Config, restoreErrors []string) { + if len(restoreErrors) > 0 { + cfg.Warningf("Some branches could not be fully restored:") + for _, e := range restoreErrors { + cfg.Printf(" %s", e) + } + } else { + cfg.Printf(" All branches restored to their original state.") + } +} + +// short returns the first 7 characters of a SHA. +func short(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/cmd/sync_test.go b/cmd/sync_test.go new file mode 100644 index 0000000..a842ae4 --- /dev/null +++ b/cmd/sync_test.go @@ -0,0 +1,649 @@ +package cmd + +import ( + "fmt" + "io" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pushCall records arguments passed to Push. +type pushCall struct { + remote string + branches []string + force bool + atomic bool +} + +// newSyncMock creates a MockOps pre-configured for sync tests. By default +// trunk and origin/trunk return the same SHA (no update needed). Override +// RevParseFn for specific test scenarios. +func newSyncMock(tmpDir string, currentBranch string) *git.MockOps { + return &git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return currentBranch, nil }, + RevParseFn: func(ref string) (string, error) { 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 }, + IsRebaseInProgressFn: func() bool { return false }, + PushFn: func(string, []string, bool, bool) error { return nil }, + } +} + +// TestSync_TrunkAlreadyUpToDate verifies that when trunk and origin/trunk have +// the same SHA, no rebase occurs and push is normal (not force). +func TestSync_TrunkAlreadyUpToDate(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) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Use same explicit SHA for local and remote trunk — already up to date + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" || ref == "origin/main" { + return "aaa111aaa111", nil + } + return "sha-" + ref, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{newBase, oldBase, branch}) + return nil + } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "rebase-" + base}) + 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() + cmd := SyncCmd(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, "up to date") + assert.Empty(t, rebaseCalls, "no rebase should occur when trunk is up to date") + + // Push should happen without force + require.Len(t, pushCalls, 1) + assert.False(t, pushCalls[0].force, "push should not use force when no rebase occurred") +} + +// TestSync_TrunkFastForward_TriggersRebase verifies that when trunk is behind +// origin/trunk, it fast-forwards and triggers a cascade rebase with force push. +func TestSync_TrunkFastForward_TriggersRebase(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) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + var updateBranchRefCalls []struct{ branch, sha string } + + mock := newSyncMock(tmpDir, "b1") + // Different SHAs for trunk vs origin/trunk + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + // local is ancestor of remote → can fast-forward + if a == "local-sha" && d == "remote-sha" { + return true, nil + } + return true, nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, struct{ branch, sha string }{branch, sha}) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(base string) error { + rebaseCalls = append(rebaseCalls, rebaseCall{branch: "(rebase)" + base}) + return nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, 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() + cmd := SyncCmd(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) + + // UpdateBranchRef should be called (not on trunk since currentBranch != trunk) + require.Len(t, updateBranchRefCalls, 1, "should fast-forward trunk via UpdateBranchRef") + assert.Equal(t, "main", updateBranchRefCalls[0].branch) + assert.Equal(t, "remote-sha", updateBranchRefCalls[0].sha) + + assert.Contains(t, output, "fast-forwarded") + + // Rebase should have been triggered + assert.NotEmpty(t, rebaseCalls, "rebase should occur after trunk fast-forward") + + // Push should use force-with-lease after rebase + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push should use force-with-lease after rebase") +} + +// TestSync_TrunkFastForward_WhenOnTrunk verifies that when currently on trunk, +// MergeFF is used instead of UpdateBranchRef. +func TestSync_TrunkFastForward_WhenOnTrunk(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var mergeFFCalls []string + var updateBranchRefCalls []string + + mock := newSyncMock(tmpDir, "main") + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.MergeFFFn = func(target string) error { + mergeFFCalls = append(mergeFFCalls, target) + return nil + } + mock.UpdateBranchRefFn = func(branch, sha string) error { + updateBranchRefCalls = append(updateBranchRefCalls, branch) + return nil + } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Len(t, mergeFFCalls, 1, "should use MergeFF when on trunk") + assert.Equal(t, "origin/main", mergeFFCalls[0]) + assert.Empty(t, updateBranchRefCalls, "should NOT use UpdateBranchRef when on trunk") +} + +// TestSync_TrunkDiverged verifies that when trunk has diverged from origin, +// no rebase occurs and a warning is shown. +func TestSync_TrunkDiverged(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseCalls []rebaseCall + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + // Neither is ancestor of the other → diverged + mock.IsAncestorFn = func(a, d string) (bool, error) { + return false, nil + } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + rebaseCalls = append(rebaseCalls, 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() + cmd := SyncCmd(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, "diverged") + assert.Empty(t, rebaseCalls, "no rebase should occur when trunk diverged") + + // Push should happen without force (no rebase occurred) + require.Len(t, pushCalls, 1) + assert.False(t, pushCalls[0].force, "push should not use force when no rebase") +} + +// TestSync_RebaseConflict_RestoresAll verifies that when a rebase conflict +// occurs during sync, all branches are restored to their original state. +func TestSync_RebaseConflict_RestoresAll(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var resets []resetCall + var checkouts []string + currentBranch := "b1" + abortCalled := false + + mock := newSyncMock(tmpDir, "b1") + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(name string) error { + checkouts = append(checkouts, name) + currentBranch = name + return nil + } + mock.RebaseFn = func(string) error { return nil } // b1 succeeds + mock.RebaseOntoFn = func(newBase, oldBase, branch string) error { + if branch == "b2" { + return fmt.Errorf("conflict") + } + return nil + } + mock.RebaseAbortFn = func() error { + abortCalled = true + return nil + } + mock.ResetHardFn = func(ref string) error { + resets = append(resets, resetCall{currentBranch, ref}) + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + assert.Error(t, err, "sync returns error on conflict") + assert.Contains(t, output, "Conflict detected") + assert.Contains(t, output, "gh stack rebase") + + // All branches should be restored + resetMap := make(map[string]string) + for _, r := range resets { + resetMap[r.branch] = r.sha + } + assert.Equal(t, "sha-b1", resetMap["b1"]) + assert.Equal(t, "sha-b2", resetMap["b2"]) + assert.Equal(t, "sha-b3", resetMap["b3"]) + + _ = abortCalled // RebaseAbort is called if IsRebaseInProgress returns true +} + +// TestSync_NoRebaseWhenTrunkDidntMove verifies that when trunk hasn't moved, +// absolutely no rebase calls are made. +func TestSync_NoRebaseWhenTrunkDidntMove(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) + + rebaseCount := 0 + rebaseOntoCount := 0 + + mock := newSyncMock(tmpDir, "b1") + // Same SHA = no trunk movement + mock.RevParseFn = func(ref string) (string, error) { + return "same-sha", nil + } + mock.RebaseFn = func(string) error { + rebaseCount++ + return nil + } + mock.RebaseOntoFn = func(string, string, string) error { + rebaseOntoCount++ + return nil + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + assert.Equal(t, 0, rebaseCount, "no Rebase calls when trunk didn't move") + assert.Equal(t, 0, rebaseOntoCount, "no RebaseOnto calls when trunk didn't move") +} + +// TestSync_PushForceFlagDependsOnRebase verifies that the force flag on Push +// correlates with whether a rebase actually happened. +func TestSync_PushForceFlagDependsOnRebase(t *testing.T) { + tests := []struct { + name string + trunkMoved bool + expectedForce bool + }{ + {"trunk_moved_force_push", true, true}, + {"trunk_static_normal_push", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + + if tt.trunkMoved { + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + } else { + mock.RevParseFn = func(ref string) (string, error) { + return "same-sha", 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, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + require.Len(t, pushCalls, 1, "exactly one push call expected") + assert.Equal(t, tt.expectedForce, pushCalls[0].force, + "force flag should be %v when trunkMoved=%v", tt.expectedForce, tt.trunkMoved) + }) + } +} + +// TestSync_SquashMergedBranch_UsesOnto verifies that when a squash-merged +// branch exists in the stack, sync's cascade rebase correctly uses --onto +// to skip the merged branch and rebase subsequent branches onto the right base. +func TestSync_SquashMergedBranch_UsesOnto(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + var rebaseOntoCalls []rebaseCall + var pushCalls []pushCall + + // Use explicit SHAs so assertions are self-documenting + branchSHAs := map[string]string{ + "b1": "b1-orig-sha", + "b2": "b2-orig-sha", + "b3": "b3-orig-sha", + } + + mock := newSyncMock(tmpDir, "b2") + // Trunk behind remote to trigger rebase + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + if sha, ok := branchSHAs[ref]; ok { + return sha, nil + } + return "default-sha", nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseOntoFn = func(newBase, oldBase, branch string) 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, _, _ := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + cfg.Err.Close() + + assert.NoError(t, err) + + // b1 is merged → skipped, needsOnto=true, ontoOldBase=b1-orig-sha + // b2: first active branch after merged → RebaseOnto(main, b1-orig-sha, b2) + // b3: normal --onto → RebaseOnto(b2, b2-orig-sha, b3) + require.Len(t, rebaseOntoCalls, 2) + assert.Equal(t, rebaseCall{"main", "b1-orig-sha", "b2"}, rebaseOntoCalls[0]) + assert.Equal(t, rebaseCall{"b2", "b2-orig-sha", "b3"}, rebaseOntoCalls[1]) + + // Push should use force (rebase happened) + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force) +} + +// TestSync_PushFailureAfterRebase verifies that when push fails after a +// successful rebase, the command does not return a fatal error — only a +// warning is printed about the push failure. +func TestSync_PushFailureAfterRebase(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) + + var pushCalls []pushCall + + mock := newSyncMock(tmpDir, "b1") + // Trunk behind remote → triggers rebase + mock.RevParseFn = func(ref string) (string, error) { + if ref == "main" { + return "local-sha", nil + } + if ref == "origin/main" { + return "remote-sha", nil + } + return "sha-" + ref, nil + } + mock.IsAncestorFn = func(a, d string) (bool, error) { + return a == "local-sha" && d == "remote-sha", nil + } + mock.UpdateBranchRefFn = func(string, string) error { return nil } + mock.CheckoutBranchFn = func(string) error { return nil } + mock.RebaseFn = func(string) error { return nil } + mock.RebaseOntoFn = func(string, string, string) error { return nil } + mock.PushFn = func(remote string, branches []string, force, atomic bool) error { + pushCalls = append(pushCalls, pushCall{remote, branches, force, atomic}) + return fmt.Errorf("network error: connection refused") + } + + restore := git.SetOps(mock) + defer restore() + + cfg, _, errR := config.NewTestConfig() + cmd := SyncCmd(cfg) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Err.Close() + errOut, _ := io.ReadAll(errR) + output := string(errOut) + + // Push failures are warnings, not fatal errors. + assert.NoError(t, err) + require.Len(t, pushCalls, 1) + assert.True(t, pushCalls[0].force, "push after rebase should use force") + assert.Contains(t, output, "Push failed") +} diff --git a/cmd/unstack.go b/cmd/unstack.go new file mode 100644 index 0000000..a8c6a12 --- /dev/null +++ b/cmd/unstack.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/stack" + "github.com/spf13/cobra" +) + +type unstackOptions struct { + target string + local bool +} + +func UnstackCmd(cfg *config.Config) *cobra.Command { + opts := &unstackOptions{} + + cmd := &cobra.Command{ + Use: "unstack [branch]", + Short: "Delete a stack locally and on GitHub", + Long: "Remove a stack from local tracking and delete it on GitHub. Use --local to only remove local tracking.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.target = args[0] + } + return runUnstack(cfg, opts) + }, + } + + cmd.Flags().BoolVar(&opts.local, "local", false, "Only delete the stack locally") + + return cmd +} + +func runUnstack(cfg *config.Config, opts *unstackOptions) error { + result, err := loadStack(cfg, opts.target) + if err != nil { + return ErrNotInStack + } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + target := opts.target + if target == "" { + target = result.CurrentBranch + } + + cfg.Printf("Stack branches: %v", s.BranchNames()) + + // Remove from local tracking + sf.RemoveStackForBranch(target) + if err := stack.Save(gitDir, sf); err != nil { + cfg.Errorf("failed to save stack state: %s", err) + return ErrSilent + } + cfg.Successf("Stack removed from local tracking") + + // Delete the stack on GitHub + if !opts.local { + client, err := cfg.GitHubClient() + if err != nil { + cfg.Errorf("failed to create GitHub client: %s", err) + return ErrAPIFailure + } + if err := client.DeleteStack(); err != nil { + cfg.Warningf("%v", err) + } else { + cfg.Successf("Stack deleted on GitHub") + } + } + + return nil +} diff --git a/cmd/unstack_test.go b/cmd/unstack_test.go new file mode 100644 index 0000000..6087887 --- /dev/null +++ b/cmd/unstack_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTwoStacks(t *testing.T, dir string, s1, s2 stack.Stack) { + t.Helper() + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{s1, s2}, + } + data, err := json.MarshalIndent(sf, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "gh-stack"), data, 0644)) +} + +func TestUnstack_RemovesStack(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "b1", nil }, + }) + defer restore() + + s1 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + cfg, outR, errR := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{} + err := runUnstack(cfg, &unstackOptions{}) + output := collectOutput(cfg, outR, errR) + + // The GitHub API call will fail (no real repo), but the command should not + // return a fatal error — only a warning is printed. + require.NoError(t, err) + assert.Contains(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, []string{"b3", "b4"}, sf.Stacks[0].BranchNames()) +} + +func TestUnstack_Local(t *testing.T) { + 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{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + }) + + cfg, outR, errR := config.NewTestConfig() + err := runUnstack(cfg, &unstackOptions{local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed") + // With --local, the GitHub API error message should NOT appear. + assert.NotContains(t, output, "failed to create GitHub client") + + sf, err := stack.Load(gitDir) + require.NoError(t, err) + assert.Empty(t, sf.Stacks) +} + +func TestUnstack_WithTarget(t *testing.T) { + gitDir := t.TempDir() + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "unrelated", nil }, + }) + defer restore() + + s1 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + s2 := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b3"}, {Branch: "b4"}}, + } + writeTwoStacks(t, gitDir, s1, s2) + + cfg, outR, errR := config.NewTestConfig() + err := runUnstack(cfg, &unstackOptions{target: "b3", local: true}) + output := collectOutput(cfg, outR, errR) + + require.NoError(t, err) + assert.Contains(t, output, "Stack removed") + + 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()) +} diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..df0a407 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,377 @@ +package cmd + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/AlecAivazis/survey/v2/terminal" + "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/stack" +) + +// ErrSilent indicates the error has already been printed to the user. +// Execute() will exit with code 1 but will not print the error again. +var ErrSilent = &ExitError{Code: 1} + +// Typed exit errors for programmatic detection by scripts and agents. +var ( + ErrNotInStack = &ExitError{Code: 2} // branch/stack not found + ErrConflict = &ExitError{Code: 3} // rebase conflict + ErrAPIFailure = &ExitError{Code: 4} // GitHub API error + ErrInvalidArgs = &ExitError{Code: 5} // invalid arguments or flags + ErrDisambiguate = &ExitError{Code: 6} // multiple stacks/remotes, can't auto-select + ErrRebaseActive = &ExitError{Code: 7} // rebase already in progress +) + +// ExitError is returned by commands to indicate a specific exit code. +// Execute() extracts the code and passes it to os.Exit. +type ExitError struct { + Code int +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("exit status %d", e.Code) +} + +func (e *ExitError) Is(target error) bool { + t, ok := target.(*ExitError) + if !ok { + return false + } + return e.Code == t.Code +} + +// errInterrupt is a sentinel returned when a prompt is cancelled via Ctrl+C. +// Callers should exit silently (the friendly message is already printed). +var errInterrupt = errors.New("interrupt") + +// isInterruptError reports whether err is (or wraps) the survey interrupt, +// which is raised when the user presses Ctrl+C during a prompt. +func isInterruptError(err error) bool { + return errors.Is(err, terminal.InterruptErr) +} + +// printInterrupt prints a friendly message and should be called exactly once +// per interrupted operation. The leading newline ensures the message starts +// on its own line even if the cursor was mid-prompt. +func printInterrupt(cfg *config.Config) { + fmt.Fprintln(cfg.Err) + cfg.Infof("Received interrupt, aborting operation") +} + +// loadStackResult holds everything returned by loadStack. +type loadStackResult struct { + GitDir string + StackFile *stack.StackFile + Stack *stack.Stack + CurrentBranch string +} + +// loadStack is the standard way to obtain a Stack for the current (or given) +// branch. It resolves the git directory, loads the stack file, determines the +// branch, calls resolveStack (which may prompt for disambiguation), checks for +// a nil stack, and re-reads the current branch (in case disambiguation caused +// a checkout). Errors are printed via cfg and returned. +func loadStack(cfg *config.Config, branch string) (*loadStackResult, error) { + gitDir, err := git.GitDir() + if err != nil { + cfg.Errorf("not a git repository") + return nil, fmt.Errorf("not a git repository") + } + + sf, err := stack.Load(gitDir) + if err != nil { + cfg.Errorf("failed to load stack state: %s", err) + return nil, fmt.Errorf("failed to load stack state: %w", err) + } + + branchFromArg := branch != "" + if branch == "" { + branch, err = git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + } + + s, err := resolveStack(sf, branch, cfg) + if err != nil { + if errors.Is(err, errInterrupt) { + return nil, errInterrupt + } + cfg.Errorf("%s", err) + return nil, err + } + if s == nil { + if branchFromArg { + cfg.Errorf("branch %q is not part of a stack", branch) + } else { + cfg.Errorf("current branch %q is not part of a stack", branch) + } + cfg.Printf("Checkout an existing stack using `%s` or create a new stack using `%s`", + cfg.ColorCyan("gh stack checkout"), cfg.ColorCyan("gh stack init")) + return nil, fmt.Errorf("branch %q is not part of a stack", branch) + } + + // Re-read current branch in case disambiguation caused a checkout. + currentBranch, err := git.CurrentBranch() + if err != nil { + cfg.Errorf("failed to get current branch: %s", err) + return nil, fmt.Errorf("failed to get current branch: %w", err) + } + + return &loadStackResult{ + GitDir: gitDir, + StackFile: sf, + Stack: s, + CurrentBranch: currentBranch, + }, nil +} + +// resolveStack finds the stack for the given branch, handling ambiguity when +// a branch (typically a trunk) belongs to multiple stacks. If exactly one +// stack matches, it is returned directly. If multiple stacks match, the user +// is prompted to select one and the working tree is switched to the top branch +// of the selected stack. Returns nil with no error if no stack contains the +// branch. +func resolveStack(sf *stack.StackFile, branch string, cfg *config.Config) (*stack.Stack, error) { + stacks := sf.FindAllStacksForBranch(branch) + + switch len(stacks) { + case 0: + return nil, nil + case 1: + return stacks[0], nil + } + + if !cfg.IsInteractive() { + return nil, fmt.Errorf("branch %q belongs to multiple stacks; use an interactive terminal to select one", branch) + } + + cfg.Warningf("Branch %q is the trunk of multiple stacks", branch) + + options := make([]string, len(stacks)) + for i, s := range stacks { + options[i] = s.DisplayChain() + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + selected, err := p.Select("Which stack would you like to use?", "", options) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return nil, errInterrupt + } + return nil, fmt.Errorf("stack selection: %w", err) + } + + s := stacks[selected] + + if len(s.Branches) == 0 { + return nil, fmt.Errorf("selected stack %q has no branches", s.DisplayChain()) + } + + // Switch to the top branch of the selected stack so future commands + // resolve unambiguously. + topBranch := s.Branches[len(s.Branches)-1].Branch + if topBranch != branch { + if err := git.CheckoutBranch(topBranch); err != nil { + return nil, fmt.Errorf("failed to checkout branch %s: %w", topBranch, err) + } + cfg.Successf("Switched to %s", topBranch) + } + + return s, nil +} + +// syncStackPRs discovers and updates pull request metadata for branches in a stack. +// For each branch, it queries GitHub for the most recent PR and updates the +// PullRequestRef including merge status. Branches with already-merged PRs are skipped. +func syncStackPRs(cfg *config.Config, s *stack.Stack) { + client, err := cfg.GitHubClient() + if err != nil { + return + } + + for i := range s.Branches { + b := &s.Branches[i] + + if b.IsMerged() { + continue + } + + pr, err := client.FindAnyPRForBranch(b.Branch) + if err != nil || pr == nil { + continue + } + + b.PullRequest = &stack.PullRequestRef{ + Number: pr.Number, + ID: pr.ID, + URL: pr.URL, + Merged: pr.Merged, + } + } +} + +// updateBaseSHAs refreshes the Base and Head SHAs for all active branches +// in a stack. Call this after any operation that may have moved branch refs +// (rebase, push, etc.). +func updateBaseSHAs(s *stack.Stack) { + // Collect all refs we need to resolve, then batch into one git call. + var refs []string + type refPair struct { + index int + parent string + branch string + } + var pairs []refPair + seen := make(map[string]bool) + for i := range s.Branches { + if s.Branches[i].IsMerged() { + continue + } + parent := s.ActiveBaseBranch(s.Branches[i].Branch) + branch := s.Branches[i].Branch + pairs = append(pairs, refPair{i, parent, branch}) + if !seen[parent] { + refs = append(refs, parent) + seen[parent] = true + } + if !seen[branch] { + refs = append(refs, branch) + seen[branch] = true + } + } + if len(refs) == 0 { + return + } + shaMap, err := git.RevParseMap(refs) + if err != nil { + return + } + for _, p := range pairs { + if base, ok := shaMap[p.parent]; ok { + s.Branches[p.index].Base = base + } + if head, ok := shaMap[p.branch]; ok { + s.Branches[p.index].Head = head + } + } +} + +// activeBranchNames returns the branch names for all non-merged branches in a stack. +func activeBranchNames(s *stack.Stack) []string { + active := s.ActiveBranches() + names := make([]string, len(active)) + for i, b := range active { + names[i] = b.Branch + } + return names +} + +// resolvePR resolves a user-provided target to a stack and branch using +// waterfall logic: PR URL → PR number → branch name. +func resolvePR(sf *stack.StackFile, target string) (*stack.Stack, *stack.BranchRef, error) { + // Try parsing as a GitHub PR URL (e.g. https://github.com/owner/repo/pull/42). + if prNumber, ok := parsePRURL(target); ok { + s, b := sf.FindStackByPRNumber(prNumber) + if s != nil && b != nil { + return s, b, nil + } + } + + // Try parsing as a PR number. + if prNumber, err := strconv.Atoi(target); err == nil && prNumber > 0 { + s, b := sf.FindStackByPRNumber(prNumber) + if s != nil && b != nil { + return s, b, nil + } + } + + // Try matching as a branch name. + stacks := sf.FindAllStacksForBranch(target) + if len(stacks) > 0 { + s := stacks[0] + idx := s.IndexOf(target) + if idx >= 0 { + return s, &s.Branches[idx], nil + } + // Target matched as trunk — return the first active branch. + if len(s.Branches) > 0 { + return s, &s.Branches[0], nil + } + } + + return nil, nil, fmt.Errorf( + "no locally tracked stack found for %q\n"+ + "This command currently only works with stacks created locally.\n"+ + "Server-side stack discovery will be available in a future release.", + target, + ) +} + +// parsePRURL extracts a PR number from a GitHub pull request URL. +// Returns the number and true if the URL matches, or 0 and false otherwise. +func parsePRURL(raw string) (int, bool) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return 0, false + } + + // Match paths like /owner/repo/pull/123 + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 4 || parts[2] != "pull" { + return 0, false + } + + n, err := strconv.Atoi(parts[3]) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + +// ensureRerere checks whether git rerere is enabled and, if not, prompts the +// user for permission before enabling it. If the user previously declined, +// the prompt is suppressed. In non-interactive sessions the function is a +// no-op so commands can still run in CI/scripting. +// +// Returns errInterrupt if the user pressed Ctrl+C during the prompt. +func ensureRerere(cfg *config.Config) error { + enabled, err := git.IsRerereEnabled() + if err != nil || enabled { + return nil + } + + declined, _ := git.IsRerereDeclined() + if declined { + return nil + } + + if !cfg.IsInteractive() { + return nil + } + + p := prompter.New(cfg.In, cfg.Out, cfg.Err) + ok, err := p.Confirm("Enable git rerere to remember conflict resolutions?", true) + if err != nil { + if isInterruptError(err) { + printInterrupt(cfg) + return errInterrupt + } + return nil + } + + if ok { + _ = git.EnableRerere() + } else { + _ = git.SaveRerereDeclined() + } + return nil +} diff --git a/cmd/utils_test.go b/cmd/utils_test.go new file mode 100644 index 0000000..2077c5d --- /dev/null +++ b/cmd/utils_test.go @@ -0,0 +1,266 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +func TestIsInterruptError_DirectMatch(t *testing.T) { + if !isInterruptError(terminal.InterruptErr) { + t.Error("expected true for terminal.InterruptErr") + } +} + +func TestIsInterruptError_Wrapped(t *testing.T) { + // This is how the prompter library wraps the interrupt error. + wrapped := fmt.Errorf("could not prompt: %w", terminal.InterruptErr) + if !isInterruptError(wrapped) { + t.Error("expected true for wrapped interrupt error") + } +} + +func TestIsInterruptError_DoubleWrapped(t *testing.T) { + // Simulate additional wrapping by callers. + inner := fmt.Errorf("could not prompt: %w", terminal.InterruptErr) + outer := fmt.Errorf("stack selection: %w", inner) + if !isInterruptError(outer) { + t.Error("expected true for double-wrapped interrupt error") + } +} + +func TestIsInterruptError_NonInterrupt(t *testing.T) { + if isInterruptError(errors.New("some other error")) { + t.Error("expected false for non-interrupt error") + } +} + +func TestIsInterruptError_Nil(t *testing.T) { + if isInterruptError(nil) { + t.Error("expected false for nil error") + } +} + +func TestPrintInterrupt_Output(t *testing.T) { + cfg, outR, errR := config.NewTestConfig() + printInterrupt(cfg) + output := collectOutput(cfg, outR, errR) + + if !strings.Contains(output, "Received interrupt, aborting operation") { + t.Errorf("expected interrupt message, got: %s", output) + } + // Should NOT contain error marker (✗) + if strings.Contains(output, "\u2717") { + t.Errorf("interrupt message should not use error format, got: %s", output) + } +} + +func TestErrInterrupt_IsDistinct(t *testing.T) { + if errors.Is(errInterrupt, terminal.InterruptErr) { + t.Error("errInterrupt sentinel should not match terminal.InterruptErr") + } + if !errors.Is(errInterrupt, errInterrupt) { + t.Error("errInterrupt should match itself") + } +} + +func TestEnsureRerere_SkipsWhenAlreadyEnabled(t *testing.T) { + enableCalled := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return true, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + _ = ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called when already enabled") + } +} + +func TestEnsureRerere_SkipsWhenDeclined(t *testing.T) { + enableCalled := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return false, nil }, + IsRerereDeclinedFn: func() (bool, error) { return true, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + }) + defer restore() + + cfg, outR, errR := config.NewTestConfig() + _ = ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called when user previously declined") + } +} + +func TestEnsureRerere_SkipsWhenNonInteractive(t *testing.T) { + enableCalled := false + declinedSaved := false + restore := git.SetOps(&git.MockOps{ + IsRerereEnabledFn: func() (bool, error) { return false, nil }, + IsRerereDeclinedFn: func() (bool, error) { return false, nil }, + EnableRerereFn: func() error { + enableCalled = true + return nil + }, + SaveRerereDeclinedFn: func() error { + declinedSaved = true + return nil + }, + }) + defer restore() + + // NewTestConfig is non-interactive (pipes, not a TTY). + cfg, outR, errR := config.NewTestConfig() + _ = ensureRerere(cfg) + collectOutput(cfg, outR, errR) + + if enableCalled { + t.Error("EnableRerere should not be called in non-interactive mode") + } + if declinedSaved { + t.Error("SaveRerereDeclined should not be called in non-interactive mode") + } +} + +func TestResolvePR_ByPRNumber(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "42") + assert.NoError(t, err) + assert.Equal(t, "feat-1", br.Branch) + assert.Equal(t, 42, br.PullRequest.Number) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_ByPRURL(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "https://github.com/o/r/pull/42") + assert.NoError(t, err) + assert.Equal(t, "feat-1", br.Branch) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_ByBranchName(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 42}}, + {Branch: "feat-2", PullRequest: &stack.PullRequestRef{Number: 43}}, + }, + }, + }, + } + + s, br, err := resolvePR(sf, "feat-2") + assert.NoError(t, err) + assert.Equal(t, "feat-2", br.Branch) + assert.Equal(t, 43, br.PullRequest.Number) + assert.Equal(t, "main", s.Trunk.Branch) +} + +func TestResolvePR_NotFound(t *testing.T) { + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat-1"}}, + }, + }, + } + + _, _, err := resolvePR(sf, "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no locally tracked stack found") +} + +func TestResolvePR_URLPrecedesNumber(t *testing.T) { + // A PR URL that contains number 99 should resolve via URL parsing, + // even if PR #99 doesn't exist — the URL parser extracts the number. + sf := &stack.StackFile{ + SchemaVersion: 1, + Stacks: []stack.Stack{ + { + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-1", PullRequest: &stack.PullRequestRef{Number: 99, URL: "https://github.com/o/r/pull/99"}}, + }, + }, + }, + } + + _, br, err := resolvePR(sf, "https://github.com/o/r/pull/99") + assert.NoError(t, err) + assert.Equal(t, 99, br.PullRequest.Number) +} + +func TestParsePRURL(t *testing.T) { + tests := []struct { + name string + input string + wantN int + wantOK bool + }{ + {"standard URL", "https://github.com/owner/repo/pull/42", 42, true}, + {"with trailing slash", "https://github.com/owner/repo/pull/42/", 42, true}, + {"with files tab", "https://github.com/owner/repo/pull/42/files", 42, true}, + {"not a PR URL", "https://github.com/owner/repo/issues/42", 0, false}, + {"plain number", "42", 0, false}, + {"branch name", "feat-1", 0, false}, + {"empty", "", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n, ok := parsePRURL(tt.input) + assert.Equal(t, tt.wantOK, ok) + if ok { + assert.Equal(t, tt.wantN, n) + } + }) + } +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..2959bf1 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,7 @@ +package cmd + +// Version is the current version of gh-stack. +// In release builds, this is overridden at build time via ldflags +// (see .github/workflows/release.yml). +// The "dev" default indicates a local development build. +var Version = "dev" diff --git a/cmd/view.go b/cmd/view.go new file mode 100644 index 0000000..1aa22b3 --- /dev/null +++ b/cmd/view.go @@ -0,0 +1,392 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/spf13/cobra" +) + +type viewOptions struct { + short bool + asJSON bool +} + +func ViewCmd(cfg *config.Config) *cobra.Command { + opts := &viewOptions{} + + cmd := &cobra.Command{ + Use: "view", + Short: "View the current stack", + RunE: func(cmd *cobra.Command, args []string) error { + return runView(cfg, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.short, "short", "s", false, "Show compact output") + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "Output stack data as JSON") + + return cmd +} + +func runView(cfg *config.Config, opts *viewOptions) error { + result, err := loadStack(cfg, "") + if err != nil { + return ErrNotInStack + } + gitDir := result.GitDir + sf := result.StackFile + s := result.Stack + currentBranch := result.CurrentBranch + + // Sync PR state + syncStackPRs(cfg, s) + _ = stack.Save(gitDir, sf) + + if opts.asJSON { + return viewJSON(cfg, s, currentBranch) + } + + if opts.short { + return viewShort(cfg, s, currentBranch) + } + + return viewFull(cfg, s, currentBranch) +} + +func viewShort(cfg *config.Config, s *stack.Stack, currentBranch string) error { + var repoOwner, repoName string + if repo, err := cfg.Repo(); err == nil { + repoOwner = repo.Owner + repoName = repo.Name + } + + for i := len(s.Branches) - 1; i >= 0; i-- { + b := s.Branches[i] + merged := b.IsMerged() + + // Insert separator when transitioning from active to merged section + if merged && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) { + cfg.Outf("├─── %s ────\n", cfg.ColorMagenta("merged")) + } + + indicator := branchStatusIndicator(cfg, s, b) + prSuffix := shortPRSuffix(cfg, b, repoOwner, repoName) + if b.Branch == currentBranch { + cfg.Outf("» %s%s%s %s\n", cfg.ColorBold(b.Branch), indicator, prSuffix, cfg.ColorCyan("(current)")) + } else if merged { + cfg.Outf("│ %s%s%s\n", cfg.ColorGray(b.Branch), indicator, prSuffix) + } else { + cfg.Outf("├ %s%s%s\n", b.Branch, indicator, prSuffix) + } + } + cfg.Outf("└ %s\n", s.Trunk.Branch) + return nil +} + +// branchStatusIndicator returns a colored status icon for a branch: +// - ✓ (purple) if the PR has been merged +// - ⚠ (yellow) if the branch needs rebasing (non-linear history) +// - ○ (green) if there is an open PR +func branchStatusIndicator(cfg *config.Config, s *stack.Stack, b stack.BranchRef) string { + if b.IsMerged() { + return " " + cfg.ColorMagenta("✓") + } + + baseBranch := s.ActiveBaseBranch(b.Branch) + if needsRebase, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !needsRebase { + return " " + cfg.ColorWarning("⚠") + } + + if b.PullRequest != nil && b.PullRequest.Number != 0 { + return " " + cfg.ColorSuccess("○") + } + + return "" +} + +// 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"` +} + +type viewJSONBranch struct { + Name string `json:"name"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + IsCurrent bool `json:"isCurrent"` + IsMerged bool `json:"isMerged"` + NeedsRebase bool `json:"needsRebase"` + PR *viewJSONPR `json:"pr,omitempty"` +} + +type viewJSONPR struct { + Number int `json:"number"` + URL string `json:"url,omitempty"` + State string `json:"state"` +} + +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)), + } + + for _, b := range s.Branches { + jb := viewJSONBranch{ + Name: b.Branch, + Head: b.Head, + Base: b.Base, + IsCurrent: b.Branch == currentBranch, + IsMerged: b.IsMerged(), + } + + // Check if the branch needs rebasing (base not ancestor of branch). + if !jb.IsMerged { + baseBranch := s.ActiveBaseBranch(b.Branch) + if isAnc, err := git.IsAncestor(baseBranch, b.Branch); err == nil && !isAnc { + jb.NeedsRebase = true + } + } + + if b.PullRequest != nil && b.PullRequest.Number != 0 { + state := "OPEN" + if b.PullRequest.Merged { + state = "MERGED" + } + jb.PR = &viewJSONPR{ + Number: b.PullRequest.Number, + URL: b.PullRequest.URL, + State: state, + } + } + + out.Branches = append(out.Branches, jb) + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + _, err = fmt.Fprintf(cfg.Out, "%s\n", data) + return err +} + +func shortPRSuffix(cfg *config.Config, b stack.BranchRef, owner, repo string) string { + if b.PullRequest == nil || b.PullRequest.Number == 0 { + return "" + } + url := b.PullRequest.URL + if url == "" && owner != "" && repo != "" { + url = fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, b.PullRequest.Number) + } + prNum := cfg.PRLink(b.PullRequest.Number, url) + colorFn := cfg.ColorSuccess // green for open + if b.PullRequest.Merged { + colorFn = cfg.ColorMagenta // purple for merged + } + return fmt.Sprintf(" %s", colorFn(prNum)) +} + +func viewFull(cfg *config.Config, s *stack.Stack, currentBranch string) error { + if !cfg.IsInteractive() { + return viewFullStatic(cfg, s, currentBranch) + } + + return viewFullTUI(cfg, s, currentBranch) +} + +func viewFullTUI(cfg *config.Config, s *stack.Stack, currentBranch string) error { + // Load enriched data for all branches + nodes := stackview.LoadBranchNodes(cfg, s, currentBranch) + + // Reverse nodes so index 0 = top of stack (matches visual order) + reversed := make([]stackview.BranchNode, len(nodes)) + for i, n := range nodes { + reversed[len(nodes)-1-i] = n + } + + model := stackview.New(reversed, s.Trunk, Version) + + p := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("running TUI: %w", err) + } + + // Checkout branch if user requested it + if m, ok := finalModel.(stackview.Model); ok { + if branch := m.CheckoutBranch(); branch != "" { + if err := git.CheckoutBranch(branch); err != nil { + cfg.Errorf("failed to checkout %s: %v", branch, err) + } else { + cfg.Successf("Switched to %s", branch) + } + } + } + + return nil +} + +func viewFullStatic(cfg *config.Config, s *stack.Stack, currentBranch string) error { + client, clientErr := cfg.GitHubClient() + + var repoOwner, repoName string + repo, repoErr := cfg.Repo() + if repoErr == nil { + repoOwner = repo.Owner + repoName = repo.Name + } + + var buf bytes.Buffer + + for i := len(s.Branches) - 1; i >= 0; i-- { + b := s.Branches[i] + + // Insert separator when transitioning from active to merged section + if b.IsMerged() && (i == len(s.Branches)-1 || !s.Branches[i+1].IsMerged()) { + fmt.Fprintf(&buf, "╌╌╌ %s ╌╌╌\n", cfg.ColorGray("merged")) + } + + isCurrent := b.Branch == currentBranch + + bullet := "○" + if isCurrent { + bullet = "●" + } + + indicator := branchStatusIndicator(cfg, s, b) + + prInfo := "" + if b.PullRequest != nil { + if url := b.PullRequest.URL; url != "" { + prInfo = " " + url + } + } else if clientErr == nil && repoErr == nil { + pr, err := client.FindPRForBranch(b.Branch) + if err == nil && pr != nil { + prInfo = fmt.Sprintf(" https://github.com/%s/%s/pull/%d", repoOwner, repoName, pr.Number) + } + } + + branchName := cfg.ColorMagenta(b.Branch) + if isCurrent { + branchName = cfg.ColorCyan(b.Branch + " (current)") + } + + fmt.Fprintf(&buf, "%s %s %s%s\n", bullet, branchName, indicator, prInfo) + + commits, err := git.Log(b.Branch, 1) + if err == nil && len(commits) > 0 { + c := commits[0] + short := c.SHA + if len(short) > 7 { + short = short[:7] + } + fmt.Fprintf(&buf, "│ %s %s\n", short, cfg.ColorGray("· "+timeAgo(c.Time))) + fmt.Fprintf(&buf, "│ %s\n", cfg.ColorGray(c.Subject)) + } + + fmt.Fprintf(&buf, "│\n") + } + + fmt.Fprintf(&buf, "└ %s\n", s.Trunk.Branch) + + return runPager(cfg, buf.String()) +} + +func runPager(cfg *config.Config, content string) error { + if !cfg.IsInteractive() { + _, err := fmt.Fprint(cfg.Out, content) + return err + } + + pagerCmd := os.Getenv("GIT_PAGER") + if pagerCmd == "" { + pagerCmd = os.Getenv("PAGER") + } + if pagerCmd == "" { + pagerCmd = "less" + } + + args := strings.Fields(pagerCmd) + if len(args) == 0 { + _, err := fmt.Fprint(cfg.Out, content) + return err + } + if args[0] == "less" { + hasR := false + for _, a := range args[1:] { + if strings.Contains(a, "R") { + hasR = true + break + } + } + if !hasR { + args = append(args, "-R") + } + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = cfg.Out + cmd.Stderr = cfg.Err + cmd.Stdin = strings.NewReader(content) + + return cmd.Run() +} + +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + secs := int(d.Seconds()) + if secs == 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secs) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case d < 30*24*time.Hour: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + default: + months := int(d.Hours() / 24 / 30) + if months <= 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} diff --git a/cmd/view_test.go b/cmd/view_test.go new file mode 100644 index 0000000..865484d --- /dev/null +++ b/cmd/view_test.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "encoding/json" + "io" + "testing" + "time" + + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTimeAgo(t *testing.T) { + tests := []struct { + name string + duration time.Duration + want string + }{ + {"seconds", 30 * time.Second, "30 seconds ago"}, + {"one second", 1 * time.Second, "1 second ago"}, + {"minutes", 5 * time.Minute, "5 minutes ago"}, + {"one minute", 1 * time.Minute, "1 minute ago"}, + {"hours", 3 * time.Hour, "3 hours ago"}, + {"one hour", 1 * time.Hour, "1 hour ago"}, + {"days", 2 * 24 * time.Hour, "2 days ago"}, + {"one day", 24 * time.Hour, "1 day ago"}, + {"months", 60 * 24 * time.Hour, "2 months ago"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := timeAgo(time.Now().Add(-tt.duration)) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestViewJSON(t *testing.T) { + git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + return true, nil // all branches are linear + }, + }) + + tests := []struct { + name string + stack *stack.Stack + currentBranch string + wantTrunk string + wantBranches int + wantCurrent string + }{ + { + name: "basic stack with PRs", + stack: &stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, + Branches: []stack.BranchRef{ + { + Branch: "feat/01", + Head: "bbb", + Base: "aaa", + PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42"}, + }, + { + Branch: "feat/02", + Head: "ccc", + Base: "bbb", + PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"}, + }, + }, + }, + currentBranch: "feat/02", + wantTrunk: "main", + wantBranches: 2, + wantCurrent: "feat/02", + }, + { + name: "stack with merged branch", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main", Head: "aaa"}, + Branches: []stack.BranchRef{ + { + Branch: "layer-1", + Head: "bbb", + Base: "aaa", + PullRequest: &stack.PullRequestRef{Number: 10, Merged: true}, + }, + { + Branch: "layer-2", + Head: "ccc", + Base: "bbb", + }, + }, + }, + currentBranch: "layer-2", + wantTrunk: "main", + wantBranches: 2, + wantCurrent: "layer-2", + }, + { + name: "empty stack", + stack: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{}, + }, + currentBranch: "main", + wantTrunk: "main", + wantBranches: 0, + wantCurrent: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, outR, _ := config.NewTestConfig() + defer outR.Close() + + err := viewJSON(cfg, tt.stack, tt.currentBranch) + require.NoError(t, err) + cfg.Out.Close() + + raw, err := io.ReadAll(outR) + require.NoError(t, err) + + var got viewJSONOutput + err = json.Unmarshal(raw, &got) + require.NoError(t, err, "output should be valid JSON: %s", string(raw)) + + assert.Equal(t, tt.wantTrunk, got.Trunk) + assert.Equal(t, tt.wantCurrent, got.CurrentBranch) + assert.Len(t, got.Branches, tt.wantBranches) + }) + } +} + +func TestViewJSON_BranchFields(t *testing.T) { + git.SetOps(&git.MockOps{ + IsAncestorFn: func(ancestor, descendant string) (bool, error) { + // feat/02 needs rebase + if descendant == "feat/02" { + return false, nil + } + return true, nil + }, + }) + + s := &stack.Stack{ + Prefix: "feat", + Trunk: stack.BranchRef{Branch: "main", Head: "aaa111"}, + Branches: []stack.BranchRef{ + { + Branch: "feat/01", + Head: "bbb222", + Base: "aaa111", + PullRequest: &stack.PullRequestRef{Number: 42, URL: "https://github.com/o/r/pull/42", Merged: true}, + }, + { + Branch: "feat/02", + Head: "ccc333", + Base: "bbb222", + PullRequest: &stack.PullRequestRef{Number: 43, URL: "https://github.com/o/r/pull/43"}, + }, + }, + } + + cfg, outR, _ := config.NewTestConfig() + defer outR.Close() + + err := viewJSON(cfg, s, "feat/02") + require.NoError(t, err) + cfg.Out.Close() + + raw, err := io.ReadAll(outR) + require.NoError(t, err) + + 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) + assert.Equal(t, "bbb222", b0.Head) + assert.Equal(t, "aaa111", b0.Base) + assert.False(t, b0.IsCurrent) + assert.True(t, b0.IsMerged) + assert.False(t, b0.NeedsRebase, "merged branches should not need rebase") + require.NotNil(t, b0.PR) + assert.Equal(t, 42, b0.PR.Number) + assert.Equal(t, "MERGED", b0.PR.State) + assert.Equal(t, "https://github.com/o/r/pull/42", b0.PR.URL) + + // Second branch: current, needs rebase + b1 := got.Branches[1] + assert.Equal(t, "feat/02", b1.Name) + assert.True(t, b1.IsCurrent) + assert.False(t, b1.IsMerged) + assert.True(t, b1.NeedsRebase) + require.NotNil(t, b1.PR) + assert.Equal(t, 43, b1.PR.Number) + assert.Equal(t, "OPEN", b1.PR.State) +} + +// TestViewShort_ActiveStack verifies that --short output contains all branch +// names and the trunk for an active stack. +func TestViewShort_ActiveStack(t *testing.T) { + s := stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "b2", nil }, + IsAncestorFn: func(string, string) (bool, error) { return true, nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + }) + defer restore() + + cfg, outR, _ := config.NewTestConfig() + cmd := ViewCmd(cfg) + cmd.SetArgs([]string{"--short"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + raw, _ := io.ReadAll(outR) + output := string(raw) + + assert.NoError(t, err) + assert.Contains(t, output, "b1") + assert.Contains(t, output, "b2") + assert.Contains(t, output, "b3") + assert.Contains(t, output, "main") +} + +// TestViewShort_FullyMergedStack verifies that --short output shows merged +// branches correctly when all branches in the stack are merged. +func TestViewShort_FullyMergedStack(t *testing.T) { + s := stack.Stack{ + 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}}, + }, + } + + tmpDir := t.TempDir() + writeStackFile(t, tmpDir, s) + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return tmpDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + IsAncestorFn: func(string, string) (bool, error) { return true, nil }, + RevParseFn: func(ref string) (string, error) { return "sha-" + ref, nil }, + }) + defer restore() + + cfg, outR, _ := config.NewTestConfig() + cmd := ViewCmd(cfg) + cmd.SetArgs([]string{"--short"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + + cfg.Out.Close() + raw, _ := io.ReadAll(outR) + output := string(raw) + + assert.NoError(t, err) + assert.Contains(t, output, "b1") + assert.Contains(t, output, "b2") +} diff --git a/go.mod b/go.mod index 81e9f07..2084dab 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,51 @@ -module github.com/githubnext/gh-stack +module github.com/github/gh-stack go 1.25.7 -require github.com/cli/go-gh/v2 v2.13.0 +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/cli/cli/v2 v2.86.0 + github.com/cli/go-gh/v2 v2.13.0 + github.com/cli/shurcooL-graphql v0.0.4 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + golang.org/x/text v0.32.0 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/cli/safeexec v1.0.0 // indirect - github.com/cli/shurcooL-graphql v0.0.4 // indirect - github.com/henvic/httpretty v0.0.6 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/henvic/httpretty v0.1.4 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/term v0.30.0 // indirect - golang.org/x/text v0.23.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/thlib/go-timezone-local v0.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ea8ce03..1050c8e 100644 --- a/go.sum +++ b/go.sum @@ -1,51 +1,158 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/cli/cli/v2 v2.86.0 h1:114DaPhDvKNMp8MTLffN119mHe040eNhNgLv3qi3mNA= +github.com/cli/cli/v2 v2.86.0/go.mod h1:cMrBHQOYc0MdNBseT5pUT6uxhvz4gcf010FEO7bWsP8= github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys= github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM= -github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= -github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= -github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= +github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= -github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= +github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/branch/name.go b/internal/branch/name.go new file mode 100644 index 0000000..c1df800 --- /dev/null +++ b/internal/branch/name.go @@ -0,0 +1,132 @@ +package branch + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +var ( + nonAlphanumRe = regexp.MustCompile(`[^a-z0-9-]+`) + multiHyphenRe = regexp.MustCompile(`-{2,}`) + numberedBranchRe = regexp.MustCompile(`/(\d+)$`) +) + +// 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. +func Slugify(message string) string { + // Normalize unicode and lowercase + s := strings.ToLower(norm.NFKD.String(message)) + + // Strip non-ASCII diacritics (combining marks) + var b strings.Builder + for _, r := range s { + if !unicode.Is(unicode.Mn, r) { // Mn = nonspacing marks + b.WriteRune(r) + } + } + s = b.String() + + // Replace non-alphanumeric chars with hyphens + s = nonAlphanumRe.ReplaceAllString(s, "-") + + // Collapse consecutive hyphens + s = multiHyphenRe.ReplaceAllString(s, "-") + + // Trim leading/trailing hyphens + 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 { + s = s[:idx] + } + } + + return s +} + +// DateSlug returns a branch name in the format YYYY-MM-DD-slugified-message. +func DateSlug(message string) string { + date := time.Now().Format("2006-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 new file mode 100644 index 0000000..04df0eb --- /dev/null +++ b/internal/branch/name_test.go @@ -0,0 +1,132 @@ +package branch + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// --- Slugify: core cases for branch naming --- + +func TestSlugify(t *testing.T) { + tests := []struct { + name string + 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"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, Slugify(tt.input)) + }) + } + + 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.NotEmpty(t, result) + }) +} + +// --- FollowsNumbering: pattern detection --- + +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") + }) + + 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("no message no name returns empty", func(t *testing.T) { + name, info := ResolveBranchName("stack", "", "", nil, false) + assert.Empty(t, name) + assert.Empty(t, info) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..27a0f4a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,125 @@ +package config + +import ( + "fmt" + "os" + + "github.com/cli/go-gh/v2/pkg/repository" + "github.com/cli/go-gh/v2/pkg/term" + "github.com/mgutz/ansi" + + ghapi "github.com/github/gh-stack/internal/github" +) + +// Config holds shared state for all commands. +type Config struct { + Terminal term.Term + Out *os.File + Err *os.File + In *os.File + + ColorSuccess func(string) string + ColorError func(string) string + ColorWarning func(string) string + ColorBold func(string) string + ColorBlue func(string) string + ColorMagenta func(string) string + ColorCyan func(string) string + ColorGray func(string) string + + // GitHubClientOverride, when non-nil, is returned by GitHubClient() + // instead of creating a real client. Used in tests to inject a MockClient. + GitHubClientOverride ghapi.ClientOps +} + +// New creates a new Config with terminal-aware output and color support. +func New() *Config { + terminal := term.FromEnv() + cfg := &Config{ + Terminal: terminal, + Out: os.Stdout, + Err: os.Stderr, + In: os.Stdin, + } + + if terminal.IsColorEnabled() { + cfg.ColorSuccess = ansi.ColorFunc("green") + cfg.ColorError = ansi.ColorFunc("red") + cfg.ColorWarning = ansi.ColorFunc("yellow") + cfg.ColorBold = ansi.ColorFunc("default+b") + cfg.ColorBlue = ansi.ColorFunc("blue") + cfg.ColorMagenta = ansi.ColorFunc("magenta") + cfg.ColorCyan = ansi.ColorFunc("cyan") + cfg.ColorGray = ansi.ColorFunc("default+d") + } else { + noop := func(s string) string { return s } + cfg.ColorSuccess = noop + cfg.ColorError = noop + cfg.ColorWarning = noop + cfg.ColorBold = noop + cfg.ColorBlue = noop + cfg.ColorMagenta = noop + cfg.ColorCyan = noop + cfg.ColorGray = noop + } + + return cfg +} + +func (c *Config) Successf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorSuccess("\u2713"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Errorf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorError("\u2717"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Warningf(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorWarning("\u26a0"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Infof(format string, args ...any) { + fmt.Fprintf(c.Err, "%s %s\n", c.ColorCyan("\u2139"), fmt.Sprintf(format, args...)) +} + +func (c *Config) Printf(format string, args ...any) { + fmt.Fprintf(c.Err, format+"\n", args...) +} + +func (c *Config) Outf(format string, args ...any) { + fmt.Fprintf(c.Out, format, args...) +} + +// PRLink formats a PR number as a clickable, underlined terminal hyperlink. +// Falls back to plain "#N" when color is disabled. +func (c *Config) PRLink(number int, url string) string { + label := fmt.Sprintf("#%d", number) + if c.Terminal.IsColorEnabled() { + if url != "" { + // OSC 8 hyperlink + label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label) + } + // Underline + label = fmt.Sprintf("\033[4m%s\033[24m", label) + } + return label +} + +func (c *Config) IsInteractive() bool { + return c.Terminal.IsTerminalOutput() +} + +func (c *Config) Repo() (repository.Repository, error) { + return repository.Current() +} + +func (c *Config) GitHubClient() (ghapi.ClientOps, error) { + if c.GitHubClientOverride != nil { + return c.GitHubClientOverride, nil + } + repo, err := c.Repo() + if err != nil { + return nil, fmt.Errorf("determining repository: %w", err) + } + return ghapi.NewClient(repo.Owner, repo.Name) +} diff --git a/internal/config/testing.go b/internal/config/testing.go new file mode 100644 index 0000000..91835b5 --- /dev/null +++ b/internal/config/testing.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "github.com/cli/go-gh/v2/pkg/term" +) + +// NewTestConfig creates a Config suitable for testing with captured output buffers. +// Color functions are no-ops, and the config is non-interactive. +func NewTestConfig() (*Config, *os.File, *os.File) { + outR, outW, _ := os.Pipe() + errR, errW, _ := os.Pipe() + + noop := func(s string) string { return s } + + cfg := &Config{ + Terminal: term.FromEnv(), + Out: outW, + Err: errW, + In: os.Stdin, + ColorSuccess: noop, + ColorError: noop, + ColorWarning: noop, + ColorBold: noop, + ColorBlue: noop, + ColorMagenta: noop, + ColorCyan: noop, + ColorGray: noop, + } + + return cfg, outR, errR +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..8af505d --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,353 @@ +package git + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + cligit "github.com/cli/cli/v2/git" +) + +// client is a shared git client used by all package-level functions. +var client = &cligit.Client{} + +// ErrMultipleRemotes is returned by ResolveRemote when multiple remotes +// are configured and none is designated as the push target. +type ErrMultipleRemotes struct { + Remotes []string +} + +func (e *ErrMultipleRemotes) Error() string { + return fmt.Sprintf("multiple remotes configured: %s", strings.Join(e.Remotes, ", ")) +} + +// CommitInfo holds metadata about a single commit. +type CommitInfo struct { + SHA string + Subject string + Body string + Time time.Time +} + +// run executes an arbitrary git command via the client and returns trimmed stdout. +func run(args ...string) (string, error) { + cmd, err := client.Command(context.Background(), args...) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// runSilent executes a git command via the client and only returns an error. +func runSilent(args ...string) error { + cmd, err := client.Command(context.Background(), args...) + if err != nil { + return err + } + return cmd.Run() +} + +// runInteractive runs a git command with stdin/stdout/stderr connected to +// the terminal, allowing interactive programs like editors to work. +func runInteractive(args ...string) error { + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// rebaseContinueOnce runs a single git rebase --continue without auto-resolve. +func rebaseContinueOnce() error { + cmd := exec.Command("git", "rebase", "--continue") + cmd.Env = append(os.Environ(), "GIT_EDITOR=true") + return cmd.Run() +} + +// tryAutoResolveRebase checks whether rerere has resolved all conflicts +// from a failed rebase. If so, it auto-continues the rebase (potentially +// multiple times for multi-commit rebases). Returns originalErr if any +// conflicts remain that need manual resolution. +func tryAutoResolveRebase(originalErr error) error { + for i := 0; i < 1000; i++ { + if !IsRebaseInProgress() { + return nil + } + conflicts, err := ConflictedFiles() + if err != nil { + return originalErr + } + if len(conflicts) > 0 { + return originalErr + } + // Rerere resolved all conflicts — auto-continue. + if rebaseContinueOnce() == nil { + return nil + } + // Continue hit another conflicting commit; loop to check + // if rerere resolved that one too. + } + return originalErr +} + +// --- Public functions delegate through the ops interface --- + +// GitDir returns the path to the .git directory. +func GitDir() (string, error) { + return ops.GitDir() +} + +// CurrentBranch returns the name of the current branch. +func CurrentBranch() (string, error) { + return ops.CurrentBranch() +} + +// BranchExists returns whether a local branch with the given name exists. +func BranchExists(name string) bool { + return ops.BranchExists(name) +} + +// CheckoutBranch switches to the specified branch. +func CheckoutBranch(name string) error { + return ops.CheckoutBranch(name) +} + +// Fetch fetches from the given remote. +func Fetch(remote string) error { + return ops.Fetch(remote) +} + +// DefaultBranch returns the HEAD branch from origin. +func DefaultBranch() (string, error) { + return ops.DefaultBranch() +} + +// CreateBranch creates a new branch from the given base. +func CreateBranch(name, base string) error { + return ops.CreateBranch(name, base) +} + +// Push pushes branches to a remote with optional force and atomic flags. +func Push(remote string, branches []string, force, atomic bool) error { + return ops.Push(remote, branches, force, atomic) +} + +// ResolveRemote determines the remote for pushing a branch. Checks git +// config in priority order, falls back to listing remotes. Returns +// *ErrMultipleRemotes if multiple remotes exist with no configured default. +func ResolveRemote(branch string) (string, error) { + return ops.ResolveRemote(branch) +} + +// Rebase rebases the current branch onto the given base. +// If rerere resolves all conflicts automatically, the rebase continues +// without user intervention. +func Rebase(base string) error { + return ops.Rebase(base) +} + +// EnableRerere enables git rerere (reuse recorded resolution) and +// rerere.autoupdate (auto-stage resolved files) for the repository. +func EnableRerere() error { + return ops.EnableRerere() +} + +// IsRerereEnabled returns whether rerere.enabled is set to "true" in git config. +func IsRerereEnabled() (bool, error) { + return ops.IsRerereEnabled() +} + +// IsRerereDeclined returns whether the user previously declined the rerere prompt. +func IsRerereDeclined() (bool, error) { + return ops.IsRerereDeclined() +} + +// SaveRerereDeclined records that the user declined the rerere prompt. +func SaveRerereDeclined() error { + return ops.SaveRerereDeclined() +} + +// RebaseOnto rebases a branch using the three-argument form: +// +// git rebase --onto +// +// This replays commits after oldBase from branch onto newBase. It is used +// when a prior branch was squash-merged and the normal rebase cannot detect +// which commits have already been applied. +// If rerere resolves all conflicts automatically, the rebase continues +// without user intervention. +func RebaseOnto(newBase, oldBase, branch string) error { + return ops.RebaseOnto(newBase, oldBase, branch) +} + +// RebaseContinue continues an in-progress rebase. +// It sets GIT_EDITOR=true to prevent git from opening an interactive editor +// for the commit message, which would cause the command to hang. +// If rerere resolves subsequent conflicts automatically, the rebase continues +// without user intervention. +func RebaseContinue() error { + return ops.RebaseContinue() +} + +// RebaseAbort aborts an in-progress rebase. +func RebaseAbort() error { + return ops.RebaseAbort() +} + +// IsRebaseInProgress checks whether a rebase is currently in progress. +func IsRebaseInProgress() bool { + return ops.IsRebaseInProgress() +} + +// ConflictedFiles returns the list of files that have merge conflicts. +func ConflictedFiles() ([]string, error) { + return ops.ConflictedFiles() +} + +// ConflictMarkerInfo holds the location of conflict markers in a file. +type ConflictMarkerInfo struct { + File string + Sections []ConflictSection +} + +// ConflictSection represents a single conflict hunk in a file. +type ConflictSection struct { + StartLine int // line number of <<<<<<< + EndLine int // line number of >>>>>>> +} + +// FindConflictMarkers scans a file for conflict markers and returns their locations. +func FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + return ops.FindConflictMarkers(filePath) +} + +// IsAncestor returns whether ancestor is an ancestor of descendant. +// This is useful to check if a fast-forward merge is possible. +func IsAncestor(ancestor, descendant string) (bool, error) { + return ops.IsAncestor(ancestor, descendant) +} + +// RevParse resolves a ref to its full SHA via git rev-parse. +func RevParse(ref string) (string, error) { + return ops.RevParse(ref) +} + +// RevParseMulti resolves multiple refs to their full SHAs in a single +// git rev-parse invocation. Returns SHAs in the same order as the input refs. +func RevParseMulti(refs []string) ([]string, error) { + return ops.RevParseMulti(refs) +} + +// RevParseMap resolves multiple refs and returns a ref→SHA map. +func RevParseMap(refs []string) (map[string]string, error) { + shas, err := ops.RevParseMulti(refs) + if err != nil { + return nil, err + } + m := make(map[string]string, len(refs)) + for i, ref := range refs { + m[ref] = shas[i] + } + return m, nil +} + +// MergeBase returns the best common ancestor commit between two refs. +func MergeBase(a, b string) (string, error) { + return ops.MergeBase(a, b) +} + +// Log returns recent commits for the given branch. +func Log(ref string, maxCount int) ([]CommitInfo, error) { + return ops.Log(ref, maxCount) +} + +// LogRange returns commits in the range base..head (commits reachable from head +// but not from base). This is useful for seeing all commits unique to a branch. +func LogRange(base, head string) ([]CommitInfo, error) { + return ops.LogRange(base, head) +} + +// DiffStatRange returns the total additions and deletions between two refs. +func DiffStatRange(base, head string) (additions, deletions int, err error) { + return ops.DiffStatRange(base, head) +} + +// FileDiffStat holds per-file diff statistics. +type FileDiffStat struct { + Path string + Additions int + Deletions int +} + +// DiffStatFiles returns per-file additions and deletions between two refs. +func DiffStatFiles(base, head string) ([]FileDiffStat, error) { + return ops.DiffStatFiles(base, head) +} + +// DeleteBranch deletes a local branch. +func DeleteBranch(name string, force bool) error { + return ops.DeleteBranch(name, force) +} + +// DeleteRemoteBranch deletes a branch on the remote. +func DeleteRemoteBranch(remote, branch string) error { + return ops.DeleteRemoteBranch(remote, branch) +} + +// ResetHard resets the current branch to the given ref. +func ResetHard(ref string) error { + return ops.ResetHard(ref) +} + +// SetUpstreamTracking sets the upstream tracking branch. +func SetUpstreamTracking(branch, remote string) error { + return ops.SetUpstreamTracking(branch, remote) +} + +// MergeFF fast-forwards the currently checked-out branch using a merge. +func MergeFF(target string) error { + return ops.MergeFF(target) +} + +// UpdateBranchRef moves a branch pointer to a new commit (for branches not currently checked out). +func UpdateBranchRef(branch, sha string) error { + return ops.UpdateBranchRef(branch, sha) +} + +// StageAll stages all changes including untracked files (git add -A). +func StageAll() error { + return ops.StageAll() +} + +// StageTracked stages changes to tracked files only (git add -u). +func StageTracked() error { + return ops.StageTracked() +} + +// HasStagedChanges returns true if there are staged changes ready to commit. +func HasStagedChanges() bool { + return ops.HasStagedChanges() +} + +// Commit creates a commit with the given message and returns the new HEAD SHA. +func Commit(message string) (string, error) { + return ops.Commit(message) +} + +// CommitInteractive launches the user's configured editor for the commit +// message, equivalent to running `git commit` without `-m`. +func CommitInteractive() (string, error) { + return ops.CommitInteractive() +} + +// ValidateRefName checks whether name is a valid git branch name. +func ValidateRefName(name string) error { + return ops.ValidateRefName(name) +} diff --git a/internal/git/gitops.go b/internal/git/gitops.go new file mode 100644 index 0000000..d6ba52d --- /dev/null +++ b/internal/git/gitops.go @@ -0,0 +1,496 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Ops defines the interface for git operations used by commands. +// The package-level functions are the default production implementation. +// Tests can substitute a mock via SetOps(). +type Ops interface { + GitDir() (string, error) + CurrentBranch() (string, error) + BranchExists(name string) bool + CheckoutBranch(name string) error + Fetch(remote string) error + DefaultBranch() (string, error) + CreateBranch(name, base string) error + Push(remote string, branches []string, force, atomic bool) error + ResolveRemote(branch string) (string, error) + Rebase(base string) error + EnableRerere() error + IsRerereEnabled() (bool, error) + IsRerereDeclined() (bool, error) + SaveRerereDeclined() error + RebaseOnto(newBase, oldBase, branch string) error + RebaseContinue() error + RebaseAbort() error + IsRebaseInProgress() bool + ConflictedFiles() ([]string, error) + FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) + IsAncestor(ancestor, descendant string) (bool, error) + RevParse(ref string) (string, error) + RevParseMulti(refs []string) ([]string, error) + MergeBase(a, b string) (string, error) + Log(ref string, maxCount int) ([]CommitInfo, error) + LogRange(base, head string) ([]CommitInfo, error) + DiffStatRange(base, head string) (additions, deletions int, err error) + DiffStatFiles(base, head string) ([]FileDiffStat, error) + DeleteBranch(name string, force bool) error + DeleteRemoteBranch(remote, branch string) error + ResetHard(ref string) error + SetUpstreamTracking(branch, remote string) error + MergeFF(target string) error + UpdateBranchRef(branch, sha string) error + StageAll() error + StageTracked() error + HasStagedChanges() bool + Commit(message string) (string, error) + CommitInteractive() (string, error) + ValidateRefName(name string) error +} + +// defaultOps implements Ops by delegating to the real git client and helpers. +type defaultOps struct{} + +var _ Ops = (*defaultOps)(nil) + +// ops is the current implementation. Tests replace this via SetOps(). +var ops Ops = &defaultOps{} + +// SetOps replaces the git operations implementation. Returns a restore function. +func SetOps(o Ops) func() { + old := ops + ops = o + return func() { ops = old } +} + +// CurrentOps returns the current Ops implementation. +func CurrentOps() Ops { + return ops +} + +// --- defaultOps method implementations --- + +func (d *defaultOps) GitDir() (string, error) { + return client.GitDir(context.Background()) +} + +func (d *defaultOps) CurrentBranch() (string, error) { + return client.CurrentBranch(context.Background()) +} + +func (d *defaultOps) BranchExists(name string) bool { + return client.HasLocalBranch(context.Background(), name) +} + +func (d *defaultOps) CheckoutBranch(name string) error { + return client.CheckoutBranch(context.Background(), name) +} + +func (d *defaultOps) Fetch(remote string) error { + return client.Fetch(context.Background(), remote, "") +} + +func (d *defaultOps) DefaultBranch() (string, error) { + ref, err := run("symbolic-ref", "refs/remotes/origin/HEAD") + if err != nil { + for _, name := range []string{"main", "master"} { + if BranchExists(name) { + return name, nil + } + } + return "", err + } + return strings.TrimPrefix(ref, "refs/remotes/origin/"), nil +} + +func (d *defaultOps) CreateBranch(name, base string) error { + return runSilent("branch", name, base) +} + +func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) error { + args := []string{"push", remote} + if force { + args = append(args, "--force-with-lease") + } + if atomic { + args = append(args, "--atomic") + } + args = append(args, branches...) + return runSilent(args...) +} + +// ResolveRemote determines the remote for pushing a branch. It checks git +// config keys in priority order (branch..pushRemote, remote.pushDefault, +// branch..remote), then falls back to listing all remotes. If exactly +// one remote exists it is returned. If multiple exist, ErrMultipleRemotes is +// returned with the list attached. If none exist, a plain error is returned. +func (d *defaultOps) ResolveRemote(branch string) (string, error) { + candidates := []string{ + "branch." + branch + ".pushRemote", + "remote.pushDefault", + "branch." + branch + ".remote", + } + for _, key := range candidates { + out, err := run("config", "--get", key) + if err == nil && out != "" { + return out, nil + } + } + + out, err := run("remote") + if err != nil { + return "", fmt.Errorf("could not list remotes: %w", err) + } + remotes := strings.Fields(strings.TrimSpace(out)) + if len(remotes) == 1 { + return remotes[0], nil + } + if len(remotes) > 1 { + return "", &ErrMultipleRemotes{Remotes: remotes} + } + return "", fmt.Errorf("no remotes configured") +} + +func (d *defaultOps) Rebase(base string) error { + err := runSilent("rebase", base) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) EnableRerere() error { + if err := runSilent("config", "rerere.enabled", "true"); err != nil { + return err + } + return runSilent("config", "rerere.autoupdate", "true") +} + +func (d *defaultOps) IsRerereEnabled() (bool, error) { + out, err := run("config", "--get", "rerere.enabled") + if err != nil { + // Missing key — not enabled. + return false, nil + } + return strings.EqualFold(strings.TrimSpace(out), "true"), nil +} + +func (d *defaultOps) IsRerereDeclined() (bool, error) { + out, err := run("config", "--get", "gh-stack.rerere-declined") + if err != nil { + return false, nil + } + return strings.EqualFold(strings.TrimSpace(out), "true"), nil +} + +func (d *defaultOps) SaveRerereDeclined() error { + return runSilent("config", "gh-stack.rerere-declined", "true") +} + +func (d *defaultOps) RebaseOnto(newBase, oldBase, branch string) error { + err := runSilent("rebase", "--onto", newBase, oldBase, branch) + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) RebaseContinue() error { + err := rebaseContinueOnce() + if err == nil { + return nil + } + return tryAutoResolveRebase(err) +} + +func (d *defaultOps) RebaseAbort() error { + return runSilent("rebase", "--abort") +} + +func (d *defaultOps) IsRebaseInProgress() bool { + gitDir, err := GitDir() + if err != nil { + return false + } + for _, dir := range []string{"rebase-merge", "rebase-apply"} { + rebasePath := filepath.Join(gitDir, dir) + if info, err := os.Stat(rebasePath); err == nil && info.IsDir() { + return true + } + } + return false +} + +func (d *defaultOps) ConflictedFiles() ([]string, error) { + output, err := run("diff", "--name-only", "--diff-filter=U") + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + return strings.Split(output, "\n"), nil +} + +func (d *defaultOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + output, err := run("diff", "--check", "--", filePath) + if output == "" && err != nil { + return nil, err + } + + info := &ConflictMarkerInfo{File: filePath} + var currentSection *ConflictSection + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 3) + if len(parts) < 3 { + continue + } + lineNo, parseErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if parseErr != nil { + continue + } + marker := strings.TrimSpace(parts[2]) + if strings.Contains(marker, "leftover conflict marker") { + if currentSection == nil || currentSection.EndLine != 0 { + currentSection = &ConflictSection{StartLine: lineNo} + info.Sections = append(info.Sections, *currentSection) + } + info.Sections[len(info.Sections)-1].EndLine = lineNo + } + } + + return info, nil +} + +func (d *defaultOps) IsAncestor(ancestor, descendant string) (bool, error) { + err := runSilent("merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err +} + +func (d *defaultOps) RevParse(ref string) (string, error) { + return run("rev-parse", ref) +} + +func (d *defaultOps) RevParseMulti(refs []string) ([]string, error) { + if len(refs) == 0 { + return nil, nil + } + args := append([]string{"rev-parse"}, refs...) + out, err := run(args...) + if err != nil { + return nil, err + } + shas := strings.Split(out, "\n") + if len(shas) != len(refs) { + return nil, fmt.Errorf("rev-parse returned %d SHAs for %d refs", len(shas), len(refs)) + } + return shas, nil +} + +func (d *defaultOps) MergeBase(a, b string) (string, error) { + return run("merge-base", a, b) +} + +func (d *defaultOps) Log(ref string, maxCount int) ([]CommitInfo, error) { + format := "%H\t%s\t%at" + output, err := run("log", ref, "--format="+format, "-n", strconv.Itoa(maxCount)) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(parts[2], 10, 64) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: parts[1], + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +func (d *defaultOps) LogRange(base, head string) ([]CommitInfo, error) { + format := "%H%x01%B%x01%at%x00" + rangeSpec := base + ".." + head + output, err := run("log", rangeSpec, "--format="+format) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + + var commits []CommitInfo + for _, record := range strings.Split(output, "\x00") { + record = strings.TrimSpace(record) + if record == "" { + continue + } + parts := strings.SplitN(record, "\x01", 3) + if len(parts) < 3 { + continue + } + ts, _ := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64) + subject, body := splitCommitMessage(parts[1]) + commits = append(commits, CommitInfo{ + SHA: parts[0], + Subject: subject, + Body: body, + Time: time.Unix(ts, 0), + }) + } + return commits, nil +} + +// splitCommitMessage splits a full commit message into subject (first line) +// and body (remaining lines with leading/trailing blank lines trimmed). +func splitCommitMessage(msg string) (subject, body string) { + msg = strings.TrimSpace(msg) + if i := strings.IndexByte(msg, '\n'); i >= 0 { + subject = msg[:i] + body = strings.TrimSpace(msg[i+1:]) + } else { + subject = msg + } + return +} + +func (d *defaultOps) DiffStatRange(base, head string) (additions, deletions int, err error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return 0, 0, err + } + if output == "" { + return 0, 0, nil + } + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + if parts[0] == "-" { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + additions += a + deletions += d + } + return additions, deletions, nil +} + +func (d *defaultOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) { + output, err := run("diff", "--numstat", base+".."+head) + if err != nil { + return nil, err + } + if output == "" { + return nil, nil + } + var files []FileDiffStat + for _, line := range strings.Split(output, "\n") { + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + files = append(files, FileDiffStat{ + Path: parts[2], + Additions: a, + Deletions: d, + }) + } + return files, nil +} + +func (d *defaultOps) DeleteBranch(name string, force bool) error { + flag := "-d" + if force { + flag = "-D" + } + return runSilent("branch", flag, name) +} + +func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error { + return runSilent("push", remote, "--delete", branch) +} + +func (d *defaultOps) ResetHard(ref string) error { + return runSilent("reset", "--hard", ref) +} + +func (d *defaultOps) SetUpstreamTracking(branch, remote string) error { + return runSilent("branch", "--set-upstream-to="+remote+"/"+branch, branch) +} + +func (d *defaultOps) MergeFF(target string) error { + return runSilent("merge", "--ff-only", target) +} + +func (d *defaultOps) UpdateBranchRef(branch, sha string) error { + return runSilent("branch", "-f", branch, sha) +} + +func (d *defaultOps) StageAll() error { + return runSilent("add", "-A") +} + +func (d *defaultOps) StageTracked() error { + return runSilent("add", "-u") +} + +func (d *defaultOps) HasStagedChanges() bool { + err := runSilent("diff", "--cached", "--quiet") + return err != nil +} + +func (d *defaultOps) Commit(message string) (string, error) { + if err := runSilent("commit", "-m", message); err != nil { + return "", err + } + return run("rev-parse", "HEAD") +} + +// CommitInteractive launches the user's editor for the commit message. +func (d *defaultOps) CommitInteractive() (string, error) { + if err := runInteractive("commit"); err != nil { + return "", err + } + return run("rev-parse", "HEAD") +} + +func (d *defaultOps) ValidateRefName(name string) error { + _, err := run("check-ref-format", "--branch", name) + return err +} diff --git a/internal/git/gitops_test.go b/internal/git/gitops_test.go new file mode 100644 index 0000000..9d0e7d4 --- /dev/null +++ b/internal/git/gitops_test.go @@ -0,0 +1,61 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitCommitMessage(t *testing.T) { + tests := []struct { + name string + msg string + wantSubject string + wantBody string + }{ + { + name: "single line", + msg: "Fix the bug", + wantSubject: "Fix the bug", + wantBody: "", + }, + { + name: "subject and body with blank separator", + msg: "Fix the bug\n\nMore details about the fix.", + wantSubject: "Fix the bug", + wantBody: "More details about the fix.", + }, + { + name: "multi-line without blank separator", + msg: "Fix the bug\nMore details\nEven more", + wantSubject: "Fix the bug", + wantBody: "More details\nEven more", + }, + { + name: "body with leading and trailing blank lines trimmed", + msg: "Fix the bug\n\n\nSome body text\n\n", + wantSubject: "Fix the bug", + wantBody: "Some body text", + }, + { + name: "whitespace-only body", + msg: "Fix the bug\n\n \n\n", + wantSubject: "Fix the bug", + wantBody: "", + }, + { + name: "leading whitespace on message trimmed", + msg: "\n Fix the bug\n\nBody here", + wantSubject: "Fix the bug", + wantBody: "Body here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subject, body := splitCommitMessage(tt.msg) + assert.Equal(t, tt.wantSubject, subject) + assert.Equal(t, tt.wantBody, body) + }) + } +} diff --git a/internal/git/mock_ops.go b/internal/git/mock_ops.go new file mode 100644 index 0000000..a8ab662 --- /dev/null +++ b/internal/git/mock_ops.go @@ -0,0 +1,338 @@ +package git + +// MockOps is a test double for git operations. +// 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) + CurrentBranchFn func() (string, error) + BranchExistsFn func(string) bool + CheckoutBranchFn func(string) error + FetchFn func(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) error + EnableRerereFn func() error + IsRerereEnabledFn func() (bool, error) + IsRerereDeclinedFn func() (bool, error) + SaveRerereDeclinedFn func() error + RebaseOntoFn func(string, string, string) error + RebaseContinueFn func() 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 + 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 +} + +var _ Ops = (*MockOps)(nil) + +func (m *MockOps) GitDir() (string, error) { + if m.GitDirFn != nil { + return m.GitDirFn() + } + return "/tmp/fake-git-dir", nil +} + +func (m *MockOps) CurrentBranch() (string, error) { + if m.CurrentBranchFn != nil { + return m.CurrentBranchFn() + } + return "main", nil +} + +func (m *MockOps) BranchExists(name string) bool { + if m.BranchExistsFn != nil { + return m.BranchExistsFn(name) + } + return false +} + +func (m *MockOps) CheckoutBranch(name string) error { + if m.CheckoutBranchFn != nil { + return m.CheckoutBranchFn(name) + } + return nil +} + +func (m *MockOps) Fetch(remote string) error { + if m.FetchFn != nil { + return m.FetchFn(remote) + } + return nil +} + +func (m *MockOps) DefaultBranch() (string, error) { + if m.DefaultBranchFn != nil { + return m.DefaultBranchFn() + } + return "main", nil +} + +func (m *MockOps) CreateBranch(name, base string) error { + if m.CreateBranchFn != nil { + return m.CreateBranchFn(name, base) + } + return nil +} + +func (m *MockOps) Push(remote string, branches []string, force, atomic bool) error { + if m.PushFn != nil { + return m.PushFn(remote, branches, force, atomic) + } + return nil +} + +func (m *MockOps) ResolveRemote(branch string) (string, error) { + if m.ResolveRemoteFn != nil { + return m.ResolveRemoteFn(branch) + } + return "origin", nil +} + +func (m *MockOps) Rebase(base string) error { + if m.RebaseFn != nil { + return m.RebaseFn(base) + } + return nil +} + +func (m *MockOps) EnableRerere() error { + if m.EnableRerereFn != nil { + return m.EnableRerereFn() + } + return nil +} + +func (m *MockOps) IsRerereEnabled() (bool, error) { + if m.IsRerereEnabledFn != nil { + return m.IsRerereEnabledFn() + } + return false, nil +} + +func (m *MockOps) IsRerereDeclined() (bool, error) { + if m.IsRerereDeclinedFn != nil { + return m.IsRerereDeclinedFn() + } + return false, nil +} + +func (m *MockOps) SaveRerereDeclined() error { + if m.SaveRerereDeclinedFn != nil { + return m.SaveRerereDeclinedFn() + } + return nil +} + +func (m *MockOps) RebaseOnto(newBase, oldBase, branch string) error { + if m.RebaseOntoFn != nil { + return m.RebaseOntoFn(newBase, oldBase, branch) + } + return nil +} + +func (m *MockOps) RebaseContinue() error { + if m.RebaseContinueFn != nil { + return m.RebaseContinueFn() + } + return nil +} + +func (m *MockOps) RebaseAbort() error { + if m.RebaseAbortFn != nil { + return m.RebaseAbortFn() + } + return nil +} + +func (m *MockOps) IsRebaseInProgress() bool { + if m.IsRebaseInProgressFn != nil { + return m.IsRebaseInProgressFn() + } + return false +} + +func (m *MockOps) ConflictedFiles() ([]string, error) { + if m.ConflictedFilesFn != nil { + return m.ConflictedFilesFn() + } + return nil, nil +} + +func (m *MockOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) { + if m.FindConflictMarkersFn != nil { + return m.FindConflictMarkersFn(filePath) + } + return nil, nil +} + +func (m *MockOps) IsAncestor(ancestor, descendant string) (bool, error) { + if m.IsAncestorFn != nil { + return m.IsAncestorFn(ancestor, descendant) + } + return false, nil +} + +func (m *MockOps) RevParse(ref string) (string, error) { + if m.RevParseFn != nil { + return m.RevParseFn(ref) + } + return "", nil +} + +func (m *MockOps) RevParseMulti(refs []string) ([]string, error) { + if m.RevParseMultiFn != nil { + return m.RevParseMultiFn(refs) + } + // Default: delegate to RevParse for each ref. + shas := make([]string, len(refs)) + for i, ref := range refs { + sha, err := m.RevParse(ref) + if err != nil { + return nil, err + } + shas[i] = sha + } + return shas, nil +} + +func (m *MockOps) MergeBase(a, b string) (string, error) { + if m.MergeBaseFn != nil { + return m.MergeBaseFn(a, b) + } + return "", nil +} + +func (m *MockOps) Log(ref string, maxCount int) ([]CommitInfo, error) { + if m.LogFn != nil { + return m.LogFn(ref, maxCount) + } + return nil, nil +} + +func (m *MockOps) LogRange(base, head string) ([]CommitInfo, error) { + if m.LogRangeFn != nil { + return m.LogRangeFn(base, head) + } + return nil, nil +} + +func (m *MockOps) DiffStatRange(base, head string) (int, int, error) { + if m.DiffStatRangeFn != nil { + return m.DiffStatRangeFn(base, head) + } + return 0, 0, nil +} + +func (m *MockOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) { + if m.DiffStatFilesFn != nil { + return m.DiffStatFilesFn(base, head) + } + return nil, nil +} + +func (m *MockOps) DeleteBranch(name string, force bool) error { + if m.DeleteBranchFn != nil { + return m.DeleteBranchFn(name, force) + } + return nil +} + +func (m *MockOps) DeleteRemoteBranch(remote, branch string) error { + if m.DeleteRemoteBranchFn != nil { + return m.DeleteRemoteBranchFn(remote, branch) + } + return nil +} + +func (m *MockOps) ResetHard(ref string) error { + if m.ResetHardFn != nil { + return m.ResetHardFn(ref) + } + return nil +} + +func (m *MockOps) SetUpstreamTracking(branch, remote string) error { + if m.SetUpstreamTrackingFn != nil { + return m.SetUpstreamTrackingFn(branch, remote) + } + return nil +} + +func (m *MockOps) MergeFF(target string) error { + if m.MergeFFFn != nil { + return m.MergeFFFn(target) + } + return nil +} + +func (m *MockOps) UpdateBranchRef(branch, sha string) error { + if m.UpdateBranchRefFn != nil { + return m.UpdateBranchRefFn(branch, sha) + } + return nil +} + +func (m *MockOps) StageAll() error { + if m.StageAllFn != nil { + return m.StageAllFn() + } + return nil +} + +func (m *MockOps) StageTracked() error { + if m.StageTrackedFn != nil { + return m.StageTrackedFn() + } + return nil +} + +func (m *MockOps) HasStagedChanges() bool { + if m.HasStagedChangesFn != nil { + return m.HasStagedChangesFn() + } + return false +} + +func (m *MockOps) Commit(message string) (string, error) { + if m.CommitFn != nil { + return m.CommitFn(message) + } + return "", nil +} + +func (m *MockOps) CommitInteractive() (string, error) { + if m.CommitInteractiveFn != nil { + return m.CommitInteractiveFn() + } + return "", nil +} + +func (m *MockOps) ValidateRefName(name string) error { + if m.ValidateRefNameFn != nil { + return m.ValidateRefNameFn(name) + } + return nil +} diff --git a/internal/github/client_interface.go b/internal/github/client_interface.go new file mode 100644 index 0000000..99af071 --- /dev/null +++ b/internal/github/client_interface.go @@ -0,0 +1,15 @@ +package github + +// ClientOps defines the interface for GitHub API operations. +// The concrete Client type satisfies this interface. +// Tests can substitute a MockClient. +type ClientOps interface { + FindPRForBranch(branch string) (*PullRequest, error) + FindAnyPRForBranch(branch string) (*PullRequest, error) + FindPRDetailsForBranch(branch string) (*PRDetails, error) + CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) + DeleteStack() error +} + +// Compile-time check that Client satisfies ClientOps. +var _ ClientOps = (*Client)(nil) diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..6cd8861 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,273 @@ +package github + +import ( + "fmt" + + "github.com/cli/go-gh/v2/pkg/api" + graphql "github.com/cli/shurcooL-graphql" +) + +// PullRequest represents a GitHub pull request. +type PullRequest struct { + ID string `graphql:"id"` + Number int `graphql:"number"` + Title string `graphql:"title"` + State string `graphql:"state"` + URL string `graphql:"url"` + HeadRefName string `graphql:"headRefName"` + BaseRefName string `graphql:"baseRefName"` + IsDraft bool `graphql:"isDraft"` + Merged bool `graphql:"merged"` +} + +// Client wraps GitHub API operations. +type Client struct { + gql *api.GraphQLClient + rest *api.RESTClient + owner string + repo string + slug string +} + +// NewClient creates a new GitHub API client for the given repository. +func NewClient(owner, repo string) (*Client, error) { + gql, err := api.DefaultGraphQLClient() + if err != nil { + return nil, fmt.Errorf("creating GraphQL client: %w", err) + } + rest, err := api.DefaultRESTClient() + if err != nil { + return nil, fmt.Errorf("creating REST client: %w", err) + } + return &Client{ + gql: gql, + rest: rest, + owner: owner, + repo: repo, + slug: owner + "/" + repo, + }, nil +} + +// FindPRForBranch finds an open PR by head branch name. +func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []PullRequest + } `graphql:"pullRequests(headRefName: $head, states: [OPEN], first: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindPRForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PRs: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PullRequest{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + HeadRefName: n.HeadRefName, + BaseRefName: n.BaseRefName, + IsDraft: n.IsDraft, + Merged: n.Merged, + }, nil +} + +// FindAnyPRForBranch finds the most recent PR by head branch name regardless of state. +func (c *Client) FindAnyPRForBranch(branch string) (*PullRequest, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []PullRequest + } `graphql:"pullRequests(headRefName: $head, last: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindAnyPRForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PRs: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PullRequest{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + HeadRefName: n.HeadRefName, + BaseRefName: n.BaseRefName, + IsDraft: n.IsDraft, + Merged: n.Merged, + }, nil +} + +// CreatePR creates a new pull request. +func (c *Client) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) { + var mutation struct { + CreatePullRequest struct { + PullRequest struct { + ID string + Number int + Title string + State string + URL string `graphql:"url"` + HeadRefName string + BaseRefName string + IsDraft bool + } + } `graphql:"createPullRequest(input: $input)"` + } + + repoID, err := c.repositoryID() + if err != nil { + return nil, err + } + + type CreatePullRequestInput struct { + RepositoryID string `json:"repositoryId"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Draft bool `json:"draft"` + } + + variables := map[string]interface{}{ + "input": CreatePullRequestInput{ + RepositoryID: repoID, + BaseRefName: base, + HeadRefName: head, + Title: title, + Body: body, + Draft: draft, + }, + } + + if err := c.gql.Mutate("CreatePullRequest", &mutation, variables); err != nil { + return nil, fmt.Errorf("creating PR: %w", err) + } + + pr := mutation.CreatePullRequest.PullRequest + return &PullRequest{ + ID: pr.ID, + Number: pr.Number, + Title: pr.Title, + State: pr.State, + URL: pr.URL, + HeadRefName: pr.HeadRefName, + BaseRefName: pr.BaseRefName, + IsDraft: pr.IsDraft, + }, nil +} + +// PRDetails holds enriched pull request data for display in the TUI. +type PRDetails struct { + Number int + Title string + State string // OPEN, CLOSED, MERGED + URL string + IsDraft bool + Merged bool + CommentsCount int +} + +// FindPRDetailsForBranch fetches enriched PR data for display purposes. +// Returns nil without error if no PR exists for the branch. +func (c *Client) FindPRDetailsForBranch(branch string) (*PRDetails, error) { + var query struct { + Repository struct { + PullRequests struct { + Nodes []struct { + ID string `graphql:"id"` + Number int `graphql:"number"` + Title string `graphql:"title"` + State string `graphql:"state"` + URL string `graphql:"url"` + HeadRefName string `graphql:"headRefName"` + BaseRefName string `graphql:"baseRefName"` + IsDraft bool `graphql:"isDraft"` + Merged bool `graphql:"merged"` + Comments struct { + TotalCount int `graphql:"totalCount"` + } `graphql:"comments"` + } + } `graphql:"pullRequests(headRefName: $head, last: 1)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + "head": graphql.String(branch), + } + + if err := c.gql.Query("FindPRDetailsForBranch", &query, variables); err != nil { + return nil, fmt.Errorf("querying PR details: %w", err) + } + + nodes := query.Repository.PullRequests.Nodes + if len(nodes) == 0 { + return nil, nil + } + + n := nodes[0] + return &PRDetails{ + Number: n.Number, + Title: n.Title, + State: n.State, + URL: n.URL, + IsDraft: n.IsDraft, + Merged: n.Merged, + CommentsCount: n.Comments.TotalCount, + }, nil +} + +// DeleteStack deletes a stack on GitHub. +// TODO: Implement once the stack API is available. +func (c *Client) DeleteStack() error { + return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API") +} + +func (c *Client) repositoryID() (string, error) { + var query struct { + Repository struct { + ID string + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": graphql.String(c.owner), + "name": graphql.String(c.repo), + } + + if err := c.gql.Query("RepositoryID", &query, variables); err != nil { + return "", fmt.Errorf("fetching repository ID: %w", err) + } + + return query.Repository.ID, nil +} diff --git a/internal/github/mock_client.go b/internal/github/mock_client.go new file mode 100644 index 0000000..5b59177 --- /dev/null +++ b/internal/github/mock_client.go @@ -0,0 +1,52 @@ +package github + +import "fmt" + +// MockClient is a test double for GitHub API operations. +// Each field is an optional function that, when set, handles the corresponding +// ClientOps method call. When nil, a reasonable default is returned. +type MockClient struct { + FindPRForBranchFn func(string) (*PullRequest, error) + FindAnyPRForBranchFn func(string) (*PullRequest, error) + FindPRDetailsForBranchFn func(string) (*PRDetails, error) + CreatePRFn func(string, string, string, string, bool) (*PullRequest, error) + DeleteStackFn func() error +} + +// Compile-time check that MockClient satisfies ClientOps. +var _ ClientOps = (*MockClient)(nil) + +func (m *MockClient) FindPRForBranch(branch string) (*PullRequest, error) { + if m.FindPRForBranchFn != nil { + return m.FindPRForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) FindAnyPRForBranch(branch string) (*PullRequest, error) { + if m.FindAnyPRForBranchFn != nil { + return m.FindAnyPRForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) FindPRDetailsForBranch(branch string) (*PRDetails, error) { + if m.FindPRDetailsForBranchFn != nil { + return m.FindPRDetailsForBranchFn(branch) + } + return nil, nil +} + +func (m *MockClient) CreatePR(base, head, title, body string, draft bool) (*PullRequest, error) { + if m.CreatePRFn != nil { + return m.CreatePRFn(base, head, title, body, draft) + } + return nil, nil +} + +func (m *MockClient) DeleteStack() error { + if m.DeleteStackFn != nil { + return m.DeleteStackFn() + } + return fmt.Errorf("deleting a stack on GitHub is not yet supported by the API") +} diff --git a/internal/stack/schema.json b/internal/stack/schema.json new file mode 100644 index 0000000..3e9f2ab --- /dev/null +++ b/internal/stack/schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "gh-stack file", + "description": "Schema for the .git/gh-stack file that stores the state of all stacks in a repository.", + "type": "object", + "required": ["schemaVersion", "stacks"], + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1, + "description": "Schema version for forward compatibility." + }, + "repository": { + "type": "string", + "description": "The host:owner/name of the repository (e.g. 'github.com:github/gh-stack')." + }, + "stacks": { + "type": "array", + "description": "All stacks tracked in this repository.", + "items": { "$ref": "#/$defs/stack" } + } + }, + "$defs": { + "stack": { + "type": "object", + "description": "A single stack of branches.", + "required": ["trunk", "branches"], + "properties": { + "id": { + "type": "string", + "description": "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." + }, + "trunk": { + "$ref": "#/$defs/branchRef", + "description": "The trunk (base) branch of the stack." + }, + "branches": { + "type": "array", + "description": "Ordered list of branches in the stack, from bottom to top.", + "items": { "$ref": "#/$defs/branchRef" } + } + } + }, + "branchRef": { + "type": "object", + "description": "A reference to a branch and its associated commit hash. For the trunk, 'head' stores the HEAD commit. For stacked branches, 'base' stores the parent branch's HEAD SHA at the time of last sync/rebase.", + "required": ["branch"], + "properties": { + "branch": { + "type": "string", + "description": "The branch name." + }, + "head": { + "type": "string", + "description": "The HEAD commit SHA of this branch. Used for the trunk branch." + }, + "base": { + "type": "string", + "description": "The parent branch's HEAD SHA at the time of last sync/rebase. Used to identify which commits are unique to this branch." + }, + "pullRequest": { + "$ref": "#/$defs/pullRequestRef", + "description": "Associated pull request information, if a PR exists for this branch." + } + } + }, + "pullRequestRef": { + "type": "object", + "description": "A snapshot of pull request metadata.", + "required": ["number"], + "properties": { + "number": { + "type": "integer", + "description": "The PR number, scoped to the repository." + }, + "id": { + "type": "string", + "description": "The PR global node ID." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Direct URL to the pull request." + }, + "merged": { + "type": "boolean", + "description": "Whether the pull request has been merged." + } + } + } + } +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go new file mode 100644 index 0000000..4dca5a6 --- /dev/null +++ b/internal/stack/stack.go @@ -0,0 +1,276 @@ +package stack + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + schemaVersion = 1 + stackFileName = "gh-stack" +) + +// PullRequestRef holds relatively immutable metadata about an associated PR. +type PullRequestRef struct { + Number int `json:"number"` + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Merged bool `json:"merged,omitempty"` +} + +// BranchRef represents a branch and its associated commit hash. +// For the trunk, Head stores the HEAD commit SHA. +// For stacked branches, Base stores the parent branch's HEAD SHA +// at the time of last sync/rebase, used to identify unique commits. +type BranchRef struct { + Branch string `json:"branch"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + PullRequest *PullRequestRef `json:"pullRequest,omitempty"` +} + +// 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"` + Trunk BranchRef `json:"trunk"` + Branches []BranchRef `json:"branches"` +} + +// DisplayChain returns a human-readable chain representation of the stack. +// Format: (trunk) <- branch1 <- branch2 <- branch3 +func (s *Stack) DisplayChain() string { + parts := []string{"(" + s.Trunk.Branch + ")"} + for _, b := range s.Branches { + parts = append(parts, b.Branch) + } + return strings.Join(parts, " <- ") +} + +// BranchNames returns the list of branch names in order. +func (s *Stack) BranchNames() []string { + names := make([]string, len(s.Branches)) + for i, b := range s.Branches { + names[i] = b.Branch + } + return names +} + +// IndexOf returns the index of the given branch in the stack, or -1 if not found. +func (s *Stack) IndexOf(branch string) int { + for i, b := range s.Branches { + if b.Branch == branch { + return i + } + } + return -1 +} + +// Contains returns true if the branch is part of this stack (including trunk). +func (s *Stack) Contains(branch string) bool { + if s.Trunk.Branch == branch { + return true + } + return s.IndexOf(branch) >= 0 +} + +// BaseBranch returns the base branch for the given branch in the stack. +// For the first branch, this is the trunk. For others, it's the previous branch. +func (s *Stack) BaseBranch(branch string) string { + idx := s.IndexOf(branch) + if idx <= 0 { + return s.Trunk.Branch + } + return s.Branches[idx-1].Branch +} + +// IsMerged returns whether a branch's PR has been merged. +func (b *BranchRef) IsMerged() bool { + return b.PullRequest != nil && b.PullRequest.Merged +} + +// ActiveBranches returns only non-merged branches, preserving order. +func (s *Stack) ActiveBranches() []BranchRef { + var active []BranchRef + for _, b := range s.Branches { + if !b.IsMerged() { + active = append(active, b) + } + } + return active +} + +// MergedBranches returns only merged branches, preserving order. +func (s *Stack) MergedBranches() []BranchRef { + var merged []BranchRef + for _, b := range s.Branches { + if b.IsMerged() { + merged = append(merged, b) + } + } + return merged +} + +// FirstActiveBranchIndex returns the index of the first non-merged branch, or -1. +func (s *Stack) FirstActiveBranchIndex() int { + for i, b := range s.Branches { + if !b.IsMerged() { + return i + } + } + return -1 +} + +// ActiveBranchIndices returns the indices of all non-merged branches. +func (s *Stack) ActiveBranchIndices() []int { + var indices []int + for i, b := range s.Branches { + if !b.IsMerged() { + indices = append(indices, i) + } + } + return indices +} + +// ActiveBaseBranch returns the effective parent for a branch, skipping merged +// ancestors. For the first active branch (or any branch whose downstack is all +// merged), this returns the trunk. +func (s *Stack) ActiveBaseBranch(branch string) string { + idx := s.IndexOf(branch) + if idx <= 0 { + return s.Trunk.Branch + } + for j := idx - 1; j >= 0; j-- { + if !s.Branches[j].IsMerged() { + return s.Branches[j].Branch + } + } + return s.Trunk.Branch +} + +// IsFullyMerged returns true if all branches in the stack have been merged. +func (s *Stack) IsFullyMerged() bool { + for _, b := range s.Branches { + if !b.IsMerged() { + return false + } + } + return len(s.Branches) > 0 +} + +// StackFile represents the JSON file stored in .git/gh-stack. +type StackFile struct { + SchemaVersion int `json:"schemaVersion"` + Repository string `json:"repository"` + Stacks []Stack `json:"stacks"` +} + +// FindAllStacksForBranch returns all stacks that contain the given branch. +func (sf *StackFile) FindAllStacksForBranch(branch string) []*Stack { + var stacks []*Stack + for i := range sf.Stacks { + if sf.Stacks[i].Contains(branch) { + stacks = append(stacks, &sf.Stacks[i]) + } + } + return stacks +} + +// FindStackByPRNumber returns the first stack and branch whose PR number matches. +// Returns nil, nil if no match is found. +func (sf *StackFile) FindStackByPRNumber(prNumber int) (*Stack, *BranchRef) { + for i := range sf.Stacks { + for j := range sf.Stacks[i].Branches { + b := &sf.Stacks[i].Branches[j] + if b.PullRequest != nil && b.PullRequest.Number == prNumber { + return &sf.Stacks[i], b + } + } + } + return nil, nil +} + +// ValidateNoDuplicateBranch checks that the branch is not already in any stack. +func (sf *StackFile) ValidateNoDuplicateBranch(branch string) error { + for _, s := range sf.Stacks { + if s.Contains(branch) { + return fmt.Errorf("branch %q is already part of a stack", branch) + } + } + return nil +} + +// AddStack adds a new stack to the file. +func (sf *StackFile) AddStack(s Stack) { + sf.Stacks = append(sf.Stacks, s) +} + +// RemoveStack removes the stack at the given index. +func (sf *StackFile) RemoveStack(idx int) { + sf.Stacks = append(sf.Stacks[:idx], sf.Stacks[idx+1:]...) +} + +// RemoveStackForBranch removes the stack containing the given branch. +func (sf *StackFile) RemoveStackForBranch(branch string) bool { + for i := range sf.Stacks { + if sf.Stacks[i].Contains(branch) { + sf.RemoveStack(i) + return true + } + } + return false +} + +// stackFilePath returns the path to the gh-stack file. +func stackFilePath(gitDir string) string { + return filepath.Join(gitDir, stackFileName) +} + +// Load reads the stack file from the given git directory. +// Returns an empty StackFile if the file does not exist. +func Load(gitDir string) (*StackFile, error) { + path := stackFilePath(gitDir) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &StackFile{ + SchemaVersion: schemaVersion, + Stacks: []Stack{}, + }, nil + } + return nil, fmt.Errorf("reading stack file: %w", err) + } + + var sf StackFile + if err := json.Unmarshal(data, &sf); err != nil { + return nil, fmt.Errorf("parsing stack file: %w", err) + } + + if sf.SchemaVersion > schemaVersion { + return nil, fmt.Errorf("stack file has schema version %d, but this version of gh-stack only supports up to version %d — please upgrade gh-stack", sf.SchemaVersion, schemaVersion) + } + + return &sf, nil +} + +// Save writes the stack file to the given git directory. +func Save(gitDir string, sf *StackFile) error { + sf.SchemaVersion = schemaVersion + if sf.Stacks == nil { + sf.Stacks = []Stack{} + } + data, err := json.MarshalIndent(sf, "", " ") + if err != nil { + return fmt.Errorf("marshaling stack file: %w", err) + } + path := stackFilePath(gitDir) + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing stack file: %w", err) + } + return nil +} diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go new file mode 100644 index 0000000..3fa65de --- /dev/null +++ b/internal/stack/stack_test.go @@ -0,0 +1,384 @@ +package stack + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func makeStack(trunk string, branches ...string) Stack { + s := Stack{Trunk: BranchRef{Branch: trunk}} + for _, b := range branches { + s.Branches = append(s.Branches, BranchRef{Branch: b}) + } + return s +} + +func makeMergedBranch(name string, prNum int) BranchRef { + return BranchRef{Branch: name, PullRequest: &PullRequestRef{Number: prNum, Merged: true}} +} + +// --- ActiveBaseBranch: skipping merged ancestors for rebase --- + +func TestActiveBaseBranch(t *testing.T) { + tests := []struct { + name string + stack Stack + branch string + expected string + }{ + { + name: "no merged ancestors returns previous branch", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1"}, + {Branch: "b2"}, + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "b2", + }, + { + name: "immediate ancestor merged skips to next non-merged", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1"}, + makeMergedBranch("b2", 10), + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "b1", + }, + { + name: "all ancestors merged returns trunk", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + {Branch: "b3"}, + }, + }, + branch: "b3", + expected: "main", + }, + { + name: "first branch always returns trunk", + stack: Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{{Branch: "b1"}}, + }, + branch: "b1", + expected: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.stack.ActiveBaseBranch(tt.branch)) + }) + } +} + +// --- ActiveBranches / MergedBranches partition --- + +func TestActiveBranches_And_MergedBranches(t *testing.T) { + t.Run("all active", func(t *testing.T) { + s := makeStack("main", "b1", "b2", "b3") + assert.Len(t, s.ActiveBranches(), 3) + assert.Empty(t, s.MergedBranches()) + }) + + t.Run("some merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + makeMergedBranch("b3", 3), + }, + } + active := s.ActiveBranches() + merged := s.MergedBranches() + + assert.Len(t, active, 1) + assert.Equal(t, "b2", active[0].Branch) + assert.Len(t, merged, 2) + assert.Equal(t, "b1", merged[0].Branch) + assert.Equal(t, "b3", merged[1].Branch) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Empty(t, s.ActiveBranches()) + assert.Len(t, s.MergedBranches(), 2) + }) +} + +// --- IsFullyMerged: blocks add on fully-merged stacks --- + +func TestIsFullyMerged(t *testing.T) { + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.True(t, s.IsFullyMerged()) + }) + + t.Run("some active", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + }, + } + assert.False(t, s.IsFullyMerged()) + }) + + t.Run("empty branches is not fully merged", func(t *testing.T) { + s := Stack{Trunk: BranchRef{Branch: "main"}} + assert.False(t, s.IsFullyMerged()) + }) +} + +// --- FirstActiveBranchIndex: navigation --- + +func TestFirstActiveBranchIndex(t *testing.T) { + t.Run("first is active", func(t *testing.T) { + s := makeStack("main", "b1", "b2") + assert.Equal(t, 0, s.FirstActiveBranchIndex()) + }) + + t.Run("first two merged third active", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + {Branch: "b3"}, + }, + } + assert.Equal(t, 2, s.FirstActiveBranchIndex()) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Equal(t, -1, s.FirstActiveBranchIndex()) + }) +} + +// --- ActiveBranchIndices: navigation --- + +func TestActiveBranchIndices(t *testing.T) { + t.Run("all active", func(t *testing.T) { + s := makeStack("main", "b1", "b2", "b3") + assert.Equal(t, []int{0, 1, 2}, s.ActiveBranchIndices()) + }) + + t.Run("some merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + {Branch: "b2"}, + makeMergedBranch("b3", 3), + {Branch: "b4"}, + }, + } + assert.Equal(t, []int{1, 3}, s.ActiveBranchIndices()) + }) + + t.Run("all merged", func(t *testing.T) { + s := Stack{ + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + makeMergedBranch("b1", 1), + makeMergedBranch("b2", 2), + }, + } + assert.Empty(t, s.ActiveBranchIndices()) + }) +} + +// --- Load / Save round-trip persistence --- + +func TestLoad_Save_RoundTrip(t *testing.T) { + t.Run("save and reload preserves all fields", func(t *testing.T) { + dir := t.TempDir() + original := &StackFile{ + Repository: "owner/repo", + Stacks: []Stack{ + { + ID: "s1", + Prefix: "feat", + Trunk: BranchRef{Branch: "main", Head: "abc123"}, + Branches: []BranchRef{ + {Branch: "b1", Head: "def456", Base: "abc123"}, + {Branch: "b2", PullRequest: &PullRequestRef{Number: 42, ID: "PR_id", URL: "https://example.com", Merged: true}}, + }, + }, + }, + } + + require.NoError(t, Save(dir, original)) + + loaded, err := Load(dir) + require.NoError(t, err) + + assert.Equal(t, schemaVersion, loaded.SchemaVersion) + assert.Equal(t, original.Repository, loaded.Repository) + require.Len(t, loaded.Stacks, 1) + + s := loaded.Stacks[0] + assert.Equal(t, "s1", s.ID) + assert.Equal(t, "feat", s.Prefix) + assert.Equal(t, "main", s.Trunk.Branch) + assert.Equal(t, "abc123", s.Trunk.Head) + require.Len(t, s.Branches, 2) + assert.Equal(t, "b1", s.Branches[0].Branch) + assert.Equal(t, "def456", s.Branches[0].Head) + assert.Equal(t, "abc123", s.Branches[0].Base) + require.NotNil(t, s.Branches[1].PullRequest) + assert.Equal(t, 42, s.Branches[1].PullRequest.Number) + assert.True(t, s.Branches[1].PullRequest.Merged) + }) + + t.Run("missing file returns empty stack file", func(t *testing.T) { + dir := t.TempDir() + sf, err := Load(dir) + require.NoError(t, err) + assert.Equal(t, schemaVersion, sf.SchemaVersion) + assert.Empty(t, sf.Stacks) + }) + + t.Run("future schema version returns error", func(t *testing.T) { + dir := t.TempDir() + data, _ := json.Marshal(StackFile{SchemaVersion: 999}) + require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), data, 0644)) + + _, err := Load(dir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "999") + }) + + t.Run("corrupt JSON returns error", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, stackFileName), []byte("{not json!"), 0644)) + + _, err := Load(dir) + assert.Error(t, err) + }) +} + +// --- FindStackByPRNumber: used by checkout --- + +func TestFindStackByPRNumber(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + { + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b1", PullRequest: &PullRequestRef{Number: 10}}, + {Branch: "b2", PullRequest: &PullRequestRef{Number: 20}}, + }, + }, + { + Trunk: BranchRef{Branch: "main"}, + Branches: []BranchRef{ + {Branch: "b3", PullRequest: &PullRequestRef{Number: 30}}, + }, + }, + }, + } + + t.Run("found", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(20) + require.NotNil(t, s) + require.NotNil(t, b) + assert.Equal(t, "b2", b.Branch) + }) + + t.Run("found in second stack", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(30) + require.NotNil(t, s) + require.NotNil(t, b) + assert.Equal(t, "b3", b.Branch) + }) + + t.Run("not found", func(t *testing.T) { + s, b := sf.FindStackByPRNumber(999) + assert.Nil(t, s) + assert.Nil(t, b) + }) +} + +// --- ValidateNoDuplicateBranch: guards against duplicates --- + +func TestValidateNoDuplicateBranch(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + makeStack("main", "b1", "b2"), + }, + } + + t.Run("branch in stack returns error", func(t *testing.T) { + assert.Error(t, sf.ValidateNoDuplicateBranch("b1")) + }) + + t.Run("trunk returns error because Contains checks trunk", func(t *testing.T) { + assert.Error(t, sf.ValidateNoDuplicateBranch("main")) + }) + + t.Run("new branch returns nil", func(t *testing.T) { + assert.NoError(t, sf.ValidateNoDuplicateBranch("new-branch")) + }) +} + +// --- RemoveStackForBranch: used by unstack --- + +func TestRemoveStackForBranch(t *testing.T) { + t.Run("found and removed", func(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{ + makeStack("main", "b1"), + makeStack("main", "b2"), + }, + } + assert.True(t, sf.RemoveStackForBranch("b1")) + require.Len(t, sf.Stacks, 1) + assert.Equal(t, "b2", sf.Stacks[0].Branches[0].Branch) + }) + + t.Run("not found", func(t *testing.T) { + sf := &StackFile{ + Stacks: []Stack{makeStack("main", "b1")}, + } + assert.False(t, sf.RemoveStackForBranch("nonexistent")) + assert.Len(t, sf.Stacks, 1) + }) +} diff --git a/internal/tui/stackview/data.go b/internal/tui/stackview/data.go new file mode 100644 index 0000000..64f8191 --- /dev/null +++ b/internal/tui/stackview/data.go @@ -0,0 +1,84 @@ +package stackview + +import ( + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +// BranchNode holds all display data for a single branch in the stack. +type BranchNode struct { + Ref stack.BranchRef + IsCurrent bool + IsLinear bool // whether history is linear with base branch + BaseBranch string + Commits []git.CommitInfo // commits unique to this branch (base..head) + FilesChanged []git.FileDiffStat // per-file diff stats + PR *ghapi.PRDetails + Additions int + Deletions int + + // UI state + CommitsExpanded bool + FilesExpanded bool +} + +// LoadBranchNodes populates branch display data from a stack. +func LoadBranchNodes(cfg *config.Config, s *stack.Stack, currentBranch string) []BranchNode { + client, clientErr := cfg.GitHubClient() + + nodes := make([]BranchNode, len(s.Branches)) + + for i, b := range s.Branches { + baseBranch := s.ActiveBaseBranch(b.Branch) + + node := BranchNode{ + Ref: b, + IsCurrent: b.Branch == currentBranch, + BaseBranch: baseBranch, + IsLinear: true, + } + + // Check linearity (is base an ancestor of this branch?) + if isAncestor, err := git.IsAncestor(baseBranch, b.Branch); err == nil { + node.IsLinear = isAncestor + } + + // For merged branches, use the merge-base (fork point) as the diff + // anchor since the base branch has moved past the merge point and + // a two-dot diff would show nothing after a squash merge. + isMerged := b.IsMerged() + diffBase := baseBranch + if isMerged { + if mb, err := git.MergeBase(baseBranch, b.Branch); err == nil { + diffBase = mb + } + } + + // Fetch commit range + if commits, err := git.LogRange(diffBase, b.Branch); err == nil { + node.Commits = commits + } + + // Compute per-file diff stats from local git + if files, err := git.DiffStatFiles(diffBase, b.Branch); err == nil { + node.FilesChanged = files + for _, f := range files { + node.Additions += f.Additions + node.Deletions += f.Deletions + } + } + + // Fetch enriched PR details + if clientErr == nil { + if pr, err := client.FindPRDetailsForBranch(b.Branch); err == nil && pr != nil { + node.PR = pr + } + } + + nodes[i] = node + } + + return nodes +} diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go new file mode 100644 index 0000000..dae09c1 --- /dev/null +++ b/internal/tui/stackview/model.go @@ -0,0 +1,965 @@ +package stackview + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/stack" +) + +// keyMap defines the key bindings for the stack view. +type keyMap struct { + Up key.Binding + Down key.Binding + ToggleCommits key.Binding + ToggleFiles key.Binding + OpenPR key.Binding + Checkout key.Binding + Quit key.Binding +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.ToggleCommits, k.ToggleFiles, k.OpenPR, k.Checkout, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} + +var keys = keyMap{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + ToggleCommits: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "commits"), + ), + ToggleFiles: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "files"), + ), + OpenPR: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "open PR"), + ), + Checkout: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "checkout"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +// headerHeight is the total number of lines the header box occupies (top border + 10 art lines + bottom border). +const headerHeight = 12 + +// minHeightForHeader is the minimum terminal height to show the header. +const minHeightForHeader = 25 + +// minWidthForShortcuts is the minimum terminal width to show keyboard shortcuts in the header. +// Below this, the header is shown without the right-side shortcuts column. +const minWidthForShortcuts = 65 + +// minWidthForHeader is the minimum terminal width to show the header at all. +const minWidthForHeader = 50 + +// artLines contains the braille ASCII art displayed in the header. +var artLines = [10]string{ + "⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀", + "⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀", + "⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀", + "⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", + "⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇", + "⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀", + "⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀", + "⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀", +} + +// artDisplayWidth is the visual column width of each art line. +const artDisplayWidth = 20 + +// 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 + + // scrollOffset tracks vertical scroll position for tall stacks. + scrollOffset int + + // checkoutBranch is set when the user wants to checkout a branch after quitting. + checkoutBranch string +} + +// New creates a new stack view model. +func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model { + h := help.New() + h.ShowAll = true + + // Cursor starts at the current branch, or top of stack + cursor := 0 + for i, n := range nodes { + if n.IsCurrent { + cursor = i + break + } + } + + return Model{ + nodes: nodes, + trunk: trunk, + version: version, + cursor: cursor, + help: h, + } +} + +// CheckoutBranch returns the branch to checkout after the TUI exits, if any. +func (m Model) CheckoutBranch() string { + return m.checkoutBranch +} + +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.help.Width = msg.Width + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + + case key.Matches(msg, keys.Up): + if m.cursor > 0 { + m.cursor-- + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.Down): + if m.cursor < len(m.nodes)-1 { + m.cursor++ + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.ToggleCommits): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].CommitsExpanded = !m.nodes[m.cursor].CommitsExpanded + m.clampScroll() + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.ToggleFiles): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].FilesExpanded = !m.nodes[m.cursor].FilesExpanded + m.clampScroll() + m.ensureVisible() + } + return m, nil + + case key.Matches(msg, keys.OpenPR): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + node := m.nodes[m.cursor] + if node.PR != nil && node.PR.URL != "" { + openBrowserInBackground(node.PR.URL) + } + } + return m, nil + + case key.Matches(msg, keys.Checkout): + if m.cursor >= 0 && m.cursor < len(m.nodes) { + node := m.nodes[m.cursor] + if !node.IsCurrent { + m.checkoutBranch = node.Ref.Branch + return m, tea.Quit + } + } + return m, nil + } + + case tea.MouseMsg: + switch msg.Action { + case tea.MouseActionPress: + if msg.Button == tea.MouseButtonLeft { + return m.handleMouseClick(msg.X, msg.Y) + } + if msg.Button == tea.MouseButtonWheelUp { + if m.scrollOffset > 0 { + m.scrollOffset-- + } + return m, nil + } + if msg.Button == tea.MouseButtonWheelDown { + m.scrollOffset++ + m.clampScroll() + return m, nil + } + } + } + + return m, nil +} + +// openBrowserInBackground launches the system browser for the given URL. +func openBrowserInBackground(url string) { + cmd := browserCmd(url) + _ = cmd.Start() +} + +// handleMouseClick processes a mouse click at the given screen position. +func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { + // If header is visible, clicks in the header area are ignored + yOffset := 0 + if m.showHeader() { + if screenY < headerHeight { + return m, nil + } + yOffset = headerHeight + } + + // Map screen Y to content line, accounting for scroll offset and header + contentLine := (screenY - yOffset) + m.scrollOffset + + // Walk through rendered lines to find which node was clicked. + // Account for the merged separator line that may appear between nodes. + line := 0 + prevWasMerged := false + for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + line++ // separator line + } + prevWasMerged = isMerged + + nodeStart := line + nodeLines := m.nodeLineCount(i) + + if contentLine >= nodeStart && contentLine < nodeStart+nodeLines { + m.cursor = i + + // Click on PR header line — only open browser if clicking the PR number + if contentLine == nodeStart && m.nodes[i].PR != nil && m.nodes[i].PR.URL != "" { + prStartX, prEndX := m.prLabelColumns(i) + if screenX >= prStartX && screenX < prEndX { + openBrowserInBackground(m.nodes[i].PR.URL) + } + } + + // Click on files toggle line → toggle expansion + if len(m.nodes[i].FilesChanged) > 0 { + filesToggleLine := nodeStart + m.filesToggleLineOffset(i) + if contentLine == filesToggleLine { + m.nodes[i].FilesExpanded = !m.nodes[i].FilesExpanded + m.clampScroll() + } + } + + // Click on commits toggle line → toggle expansion + if len(m.nodes[i].Commits) > 0 { + commitToggleLine := nodeStart + m.commitToggleLineOffset(i) + if contentLine == commitToggleLine { + m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded + m.clampScroll() + } + } + + return m, nil + } + line += nodeLines + } + + return m, nil +} + +// nodeLineCount returns how many rendered lines a node occupies. +func (m Model) nodeLineCount(idx int) int { + node := m.nodes[idx] + lines := 1 // header line (PR line or branch line) + + if node.PR != nil { + lines++ // branch + diff stats line (below PR header) + } + + if len(node.FilesChanged) > 0 { + lines++ // files toggle line + if node.FilesExpanded { + lines += len(node.FilesChanged) + } + } + + if len(node.Commits) > 0 { + lines++ // commits toggle line + if node.CommitsExpanded { + lines += len(node.Commits) + } + } + + lines++ // connector/spacer line + return lines +} + +// commitToggleLineOffset returns the offset from node start to the commits toggle line. +func (m Model) commitToggleLineOffset(idx int) int { + node := m.nodes[idx] + offset := 1 // after header + if node.PR != nil { + offset++ // branch + diff line + } + if len(node.FilesChanged) > 0 { + offset++ // files toggle line + if node.FilesExpanded { + offset += len(node.FilesChanged) + } + } + return offset +} + +// filesToggleLineOffset returns the offset from node start to the files toggle line. +func (m Model) filesToggleLineOffset(idx int) int { + node := m.nodes[idx] + offset := 1 // after header + if node.PR != nil { + offset++ // branch + diff line + } + return offset +} + +// prLabelColumns returns the start and end X columns of the PR number label +// (e.g. "#123") on the PR header line, for click hit-testing. +func (m Model) prLabelColumns(idx int) (int, int) { + node := m.nodes[idx] + // Layout: "├ " (2) + optional status icon + " " (2) + "#N..." + col := 2 // bullet + space + if node.PR != nil && (node.PR.Merged || !node.IsLinear || node.PR.Number != 0) { + icon := m.statusIcon(node) + if icon != "" { + col += 2 // icon (1 visible char) + space + } + } + prLabel := fmt.Sprintf("#%d", node.PR.Number) + return col, col + len(prLabel) +} + +// ensureVisible adjusts scroll offset so the cursor is visible. +func (m *Model) ensureVisible() { + if m.height == 0 { + return + } + + // Calculate the line range for the cursor node, accounting for separator lines + startLine := 0 + prevWasMerged := false + for i := 0; i < m.cursor; i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + startLine++ // separator line + } + prevWasMerged = isMerged + startLine += m.nodeLineCount(i) + } + // Check if the cursor node itself is preceded by a separator + if m.cursor < len(m.nodes) { + isMerged := m.nodes[m.cursor].Ref.IsMerged() + if isMerged && !prevWasMerged && m.cursor > 0 { + startLine++ + } + } + endLine := startLine + m.nodeLineCount(m.cursor) + + // Available content height (reserve space for header or help bar) + viewHeight := m.contentViewHeight() + if viewHeight < 1 { + viewHeight = 1 + } + + if startLine < m.scrollOffset { + m.scrollOffset = startLine + } + if endLine > m.scrollOffset+viewHeight { + m.scrollOffset = endLine - viewHeight + } +} + +// showHeader returns true if the terminal is large enough for the header. +func (m Model) showHeader() bool { + return m.height >= minHeightForHeader && m.width >= minWidthForHeader +} + +// showShortcuts returns true if the terminal is wide enough for the shortcuts column in the header. +func (m Model) showShortcuts() bool { + return m.width >= minWidthForShortcuts +} + +// totalContentLines returns the total number of rendered content lines (excluding header). +func (m Model) totalContentLines() int { + lines := 0 + prevWasMerged := false + for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + lines++ // separator line + } + prevWasMerged = isMerged + lines += m.nodeLineCount(i) + } + lines++ // trunk line + return lines +} + +// contentViewHeight returns the number of lines available for stack content. +func (m Model) contentViewHeight() int { + reserved := 0 + if m.showHeader() { + reserved = headerHeight + } + h := m.height - reserved + if h < 1 { + h = 1 + } + return h +} + +// clampScroll ensures scrollOffset doesn't exceed content bounds. +func (m *Model) clampScroll() { + maxScroll := m.totalContentLines() - m.contentViewHeight() + if maxScroll < 0 { + maxScroll = 0 + } + if m.scrollOffset > maxScroll { + m.scrollOffset = maxScroll + } + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } +} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + + var out strings.Builder + + showHeader := m.showHeader() + if showHeader { + m.renderHeader(&out) + } + + var b strings.Builder + + // Render nodes in order (index 0 = top of stack, displayed first) + prevWasMerged := false + for i := 0; i < len(m.nodes); i++ { + isMerged := m.nodes[i].Ref.IsMerged() + if isMerged && !prevWasMerged && i > 0 { + b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" merged ") + connectorStyle.Render("─────") + "\n") + } + m.renderNode(&b, i) + prevWasMerged = isMerged + } + + // Trunk + b.WriteString(connectorStyle.Render("└ ")) + b.WriteString(trunkStyle.Render(m.trunk.Branch)) + b.WriteString("\n") + + content := b.String() + contentLines := strings.Split(content, "\n") + + // Apply scrolling + reservedLines := 0 + if showHeader { + reservedLines = headerHeight + } + viewHeight := m.height - reservedLines + if viewHeight < 1 { + viewHeight = 1 + } + + // Clamp scroll offset so we can't scroll past content + maxScroll := len(contentLines) - viewHeight + if maxScroll < 0 { + maxScroll = 0 + } + start := m.scrollOffset + if start > maxScroll { + start = maxScroll + } + end := start + viewHeight + if end > len(contentLines) { + end = len(contentLines) + } + + visibleContent := strings.Join(contentLines[start:end], "\n") + out.WriteString(visibleContent) + + return out.String() +} + +// renderHeader renders the full-width stylized header box with ASCII art, stack info, and keyboard shortcuts. +func (m Model) renderHeader(b *strings.Builder) { + w := m.width + if w < 2 { + return + } + innerWidth := w - 2 // subtract left and right border chars + + // Build info lines (placed to the right of art on specific rows) + mergedCount := 0 + for _, n := range m.nodes { + if n.Ref.IsMerged() { + mergedCount++ + } + } + branchCount := len(m.nodes) + branchInfo := fmt.Sprintf("%d branches", branchCount) + if branchCount == 1 { + branchInfo = "1 branch" + } + if mergedCount > 0 { + branchInfo += fmt.Sprintf(" (%d merged)", mergedCount) + } + + // Branch progress icon: ○ none merged, ◐ some merged, ● all merged + branchIcon := "○" + if mergedCount > 0 && mergedCount < branchCount { + branchIcon = "◐" + } else if branchCount > 0 && mergedCount == branchCount { + branchIcon = "●" + } + + // Info text mapped to art row indices (0-based) + infoByRow := map[int]string{ + 2: headerTitleStyle.Render("GitHub Stacks"), + 3: headerInfoLabelStyle.Render("v" + m.version), + 5: headerInfoStyle.Render("✓") + headerInfoLabelStyle.Render(" Stack initialized"), + 6: headerInfoStyle.Render("◆") + headerInfoLabelStyle.Render(" Base: "+m.trunk.Branch), + 7: headerInfoStyle.Render(branchIcon) + headerInfoLabelStyle.Render(" "+branchInfo), + } + + showShortcuts := m.showShortcuts() + + // Build shortcut lines (rendered content + visual widths) + type shortcutLine struct { + text string + visWidth int + } + var shortcuts []shortcutLine + maxShortcutWidth := 0 + rightColWidth := 0 + + if showShortcuts { + shortcuts = []shortcutLine{ + {headerShortcutKey.Render("↑") + headerShortcutDesc.Render(" up ") + + headerShortcutKey.Render("↓") + headerShortcutDesc.Render(" down"), 0}, + {headerShortcutKey.Render("c") + headerShortcutDesc.Render(" commits"), 0}, + {headerShortcutKey.Render("f") + headerShortcutDesc.Render(" files"), 0}, + {headerShortcutKey.Render("o") + headerShortcutDesc.Render(" open PR"), 0}, + {headerShortcutKey.Render("↵") + headerShortcutDesc.Render(" checkout"), 0}, + {headerShortcutKey.Render("q") + headerShortcutDesc.Render(" quit"), 0}, + } + for i := range shortcuts { + shortcuts[i].visWidth = lipgloss.Width(shortcuts[i].text) + if shortcuts[i].visWidth > maxShortcutWidth { + maxShortcutWidth = shortcuts[i].visWidth + } + } + rightColWidth = maxShortcutWidth + 2 + } + + // Left content base: 1 (margin) + artDisplayWidth + leftContentBase := 1 + artDisplayWidth + + // Vertically center shortcuts within the 10 content rows + scStartRow := 0 + if len(shortcuts) > 0 { + scStartRow = (10 - len(shortcuts)) / 2 + } + + // Top border + b.WriteString(headerBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐")) + b.WriteString("\n") + + // Content rows + gap := " " // gap between art and info text + for i := 0; i < 10; i++ { + art := artLines[i] + + // Build info segment + infoText := "" + infoVisualLen := 0 + if info, ok := infoByRow[i]; ok { + infoText = gap + info + infoVisualLen = 2 + lipgloss.Width(info) + } + + leftUsed := leftContentBase + infoVisualLen + + if showShortcuts { + // Two-column layout: left (art+info) | right (shortcuts) + shortcutCol := innerWidth - rightColWidth + midPad := shortcutCol - leftUsed + if midPad < 0 { + midPad = 0 + } + + scIdx := i - scStartRow + shortcutRendered := "" + scVisWidth := 0 + if scIdx >= 0 && scIdx < len(shortcuts) { + shortcutRendered = shortcuts[scIdx].text + scVisWidth = shortcuts[scIdx].visWidth + } + scTrailingPad := rightColWidth - scVisWidth + if scTrailingPad < 0 { + scTrailingPad = 0 + } + + b.WriteString(headerBorderStyle.Render("│")) + b.WriteString(" ") + b.WriteString(art) + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", midPad)) + b.WriteString(shortcutRendered) + b.WriteString(strings.Repeat(" ", scTrailingPad)) + b.WriteString(headerBorderStyle.Render("│")) + } else { + // Single-column layout: art + info, padded to fill + trailingPad := innerWidth - leftUsed + if trailingPad < 0 { + trailingPad = 0 + } + + b.WriteString(headerBorderStyle.Render("│")) + b.WriteString(" ") + b.WriteString(art) + b.WriteString(infoText) + b.WriteString(strings.Repeat(" ", trailingPad)) + b.WriteString(headerBorderStyle.Render("│")) + } + b.WriteString("\n") + } + + // Bottom border + b.WriteString(headerBorderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘")) + b.WriteString("\n") +} + +// renderNode renders a single branch node. +func (m Model) renderNode(b *strings.Builder, idx int) { + node := m.nodes[idx] + isFocused := idx == m.cursor + + // Determine connector character and style + connector := "│" + connStyle := connectorStyle + isMerged := node.PR != nil && node.PR.Merged + if !node.IsLinear && !isMerged { + connector = "┊" + connStyle = connectorDashedStyle + } + // Override style when this node is focused + if isFocused { + if node.IsCurrent { + connStyle = connectorCurrentStyle + } else if isMerged { + connStyle = connectorMergedStyle + } else { + connStyle = connectorFocusedStyle + } + } + + // Render header: either PR line + branch line, or just branch line + if node.PR != nil { + m.renderPRHeader(b, node, isFocused, connStyle) + m.renderBranchLine(b, node, connector, connStyle) + } else { + m.renderBranchHeader(b, node, isFocused, connStyle) + } + + // Files changed toggle + expanded file list + if len(node.FilesChanged) > 0 { + m.renderFiles(b, node, connector, connStyle) + } + + // Commits toggle + expanded commits + if len(node.Commits) > 0 { + m.renderCommits(b, node, connector, connStyle) + } + + // Connector/spacer + b.WriteString(connStyle.Render(connector)) + b.WriteString("\n") +} + +// renderPRHeader renders the top line when a PR exists: bullet + status icon + PR number + state. +func (m Model) renderPRHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { + bullet := "├" + if isFocused { + bullet = "▶" + } + + b.WriteString(connStyle.Render(bullet + " ")) + + statusIcon := m.statusIcon(node) + + if statusIcon != "" { + b.WriteString(statusIcon + " ") + } + + // PR number + state label + pr := node.PR + prLabel := fmt.Sprintf("#%d", pr.Number) + stateLabel := "" + style := prOpenStyle + switch { + case pr.Merged: + stateLabel = " MERGED" + style = prMergedStyle + case pr.State == "CLOSED": + stateLabel = " CLOSED" + style = prClosedStyle + case pr.IsDraft: + stateLabel = " DRAFT" + style = prDraftStyle + default: + stateLabel = " OPEN" + } + b.WriteString(style.Underline(true).Render(prLabel) + style.Render(stateLabel)) + + b.WriteString("\n") +} + +// renderBranchLine renders the branch name + diff stats below the PR header. +func (m Model) renderBranchLine(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + branchName := node.Ref.Branch + if node.IsCurrent { + b.WriteString(currentBranchStyle.Render(branchName + " (current)")) + } else if node.PR != nil && node.PR.Merged { + b.WriteString(normalBranchStyle.Render(branchName)) + } else { + b.WriteString(normalBranchStyle.Render(branchName)) + } + + m.renderDiffStats(b, node) + b.WriteString("\n") +} + +// renderBranchHeader renders the header line when there is no PR: bullet + branch name + diff stats. +func (m Model) renderBranchHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) { + bullet := "├" + if isFocused { + bullet = "▶" + } + + b.WriteString(connStyle.Render(bullet + " ")) + + // Status indicator + statusIcon := m.statusIcon(node) + if statusIcon != "" { + b.WriteString(statusIcon + " ") + } + + // Branch name + branchName := node.Ref.Branch + if node.IsCurrent { + b.WriteString(currentBranchStyle.Render(branchName + " (current)")) + } else { + b.WriteString(normalBranchStyle.Render(branchName)) + } + + m.renderDiffStats(b, node) + b.WriteString("\n") +} + +// renderDiffStats appends +N -N diff stats to the current line if available. +func (m Model) renderDiffStats(b *strings.Builder, node BranchNode) { + if node.Additions > 0 || node.Deletions > 0 { + b.WriteString(" ") + b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", node.Additions))) + b.WriteString(" ") + b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions))) + } +} + +// statusIcon returns the appropriate status icon for a branch. +func (m Model) statusIcon(node BranchNode) string { + if node.PR != nil && node.PR.Merged { + return mergedIcon + } + if !node.IsLinear { + return warningIcon + } + if node.PR != nil && node.PR.Number != 0 { + return openIcon + } + return "" +} + +// renderFiles renders the files changed toggle and optionally the expanded file list. +func (m Model) renderFiles(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := collapsedIcon + if node.FilesExpanded { + icon = expandedIcon + } + fileLabel := "files changed" + if len(node.FilesChanged) == 1 { + fileLabel = "file changed" + } + b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel))) + b.WriteString("\n") + + if !node.FilesExpanded { + return + } + + for _, f := range node.FilesChanged { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + path := f.Path + maxLen := m.width - 30 + if maxLen < 20 { + maxLen = 20 + } + if len(path) > maxLen { + path = "…" + path[len(path)-maxLen+1:] + } + b.WriteString(normalBranchStyle.Render(path)) + b.WriteString(" ") + b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", f.Additions))) + b.WriteString(" ") + b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions))) + b.WriteString("\n") + } +} + +// renderCommits renders the commits toggle and optionally the expanded commit list. +func (m Model) renderCommits(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + icon := collapsedIcon + if node.CommitsExpanded { + icon = expandedIcon + } + commitLabel := "commits" + if len(node.Commits) == 1 { + commitLabel = "commit" + } + b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel))) + b.WriteString("\n") + + if !node.CommitsExpanded { + return + } + + for _, c := range node.Commits { + b.WriteString(connStyle.Render(connector)) + b.WriteString(" ") + + sha := c.SHA + if len(sha) > 7 { + sha = sha[:7] + } + b.WriteString(commitSHAStyle.Render(sha)) + b.WriteString(" ") + + subject := c.Subject + maxLen := m.width - 35 + if maxLen < 20 { + maxLen = 20 + } + if len(subject) > maxLen { + subject = subject[:maxLen-1] + "…" + } + b.WriteString(commitSubjectStyle.Render(subject)) + b.WriteString(" ") + b.WriteString(commitTimeStyle.Render(timeAgo(c.Time))) + b.WriteString("\n") + } +} + +// timeAgo returns a human-readable time-ago string. +func timeAgo(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + secs := int(d.Seconds()) + if secs == 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secs) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case d < 30*24*time.Hour: + days := int(d.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + default: + months := int(d.Hours() / 24 / 30) + if months <= 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + } +} + +// browserCmd returns an exec.Cmd to open a URL in the default browser. +func browserCmd(url string) *exec.Cmd { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url) + case "windows": + return exec.Command("cmd", "/c", "start", url) + default: + return exec.Command("xdg-open", url) + } +} diff --git a/internal/tui/stackview/model_test.go b/internal/tui/stackview/model_test.go new file mode 100644 index 0000000..60e44c5 --- /dev/null +++ b/internal/tui/stackview/model_test.go @@ -0,0 +1,318 @@ +package stackview + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/stretchr/testify/assert" +) + +func makeNodes(branches ...string) []BranchNode { + nodes := make([]BranchNode, len(branches)) + for i, b := range branches { + nodes[i] = BranchNode{ + Ref: stack.BranchRef{Branch: b}, + } + } + return nodes +} + +func keyMsg(k string) tea.KeyMsg { + switch k { + case "up": + return tea.KeyMsg(tea.Key{Type: tea.KeyUp}) + case "down": + return tea.KeyMsg(tea.Key{Type: tea.KeyDown}) + case "enter": + return tea.KeyMsg(tea.Key{Type: tea.KeyEnter}) + case "esc": + return tea.KeyMsg(tea.Key{Type: tea.KeyEscape}) + case "ctrl+c": + return tea.KeyMsg(tea.Key{Type: tea.KeyCtrlC}) + default: + // Single rune key like 'c', 'f', 'q', 'o' + return tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(k)}) + } +} + +var testTrunk = stack.BranchRef{Branch: "main"} + +func TestNew_CursorAtCurrentBranch(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + nodes[1].IsCurrent = true + + m := New(nodes, testTrunk, "0.0.1") + + assert.Equal(t, 1, m.cursor) +} + +func TestNew_CursorAtZeroWhenNoCurrent(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + + m := New(nodes, testTrunk, "0.0.1") + + assert.Equal(t, 0, m.cursor) +} + +func TestUpdate_KeyboardNavigation(t *testing.T) { + nodes := makeNodes("b1", "b2", "b3") + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 0, m.cursor) + + // Down + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Down again + updated, _ = m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor) + + // Down at bottom — should clamp + updated, _ = m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 2, m.cursor, "cursor should clamp at bottom") + + // Up + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Up + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor) + + // Up at top — should clamp + updated, _ = m.Update(keyMsg("up")) + m = updated.(Model) + assert.Equal(t, 0, m.cursor, "cursor should clamp at top") +} + +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") + + assert.False(t, m.nodes[0].CommitsExpanded) + + updated, _ := m.Update(keyMsg("c")) + m = updated.(Model) + assert.True(t, m.nodes[0].CommitsExpanded) + + // Toggle back + updated, _ = m.Update(keyMsg("c")) + m = updated.(Model) + assert.False(t, m.nodes[0].CommitsExpanded) +} + +func TestUpdate_ToggleFiles(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + assert.False(t, m.nodes[0].FilesExpanded) + + updated, _ := m.Update(keyMsg("f")) + m = updated.(Model) + assert.True(t, m.nodes[0].FilesExpanded) + + // Toggle back + updated, _ = m.Update(keyMsg("f")) + m = updated.(Model) + assert.False(t, m.nodes[0].FilesExpanded) +} + +func TestUpdate_Quit(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk, "0.0.1") + + quitKeys := []string{"q", "esc", "ctrl+c"} + for _, k := range quitKeys { + t.Run(k, func(t *testing.T) { + _, cmd := m.Update(keyMsg(k)) + assert.NotNil(t, cmd, "key %q should produce a quit command", k) + }) + } +} + +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") + + // Move to b2 (non-current) + updated, _ := m.Update(keyMsg("down")) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) + + // Press enter on non-current node + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + + assert.Equal(t, "b2", m.CheckoutBranch()) + assert.NotNil(t, cmd, "enter on non-current should produce quit command") +} + +func TestUpdate_EnterOnCurrentDoesNothing(t *testing.T) { + nodes := makeNodes("b1", "b2") + nodes[0].IsCurrent = true + m := New(nodes, testTrunk, "0.0.1") + assert.Equal(t, 0, m.cursor) + + // Press enter on current node + updated, cmd := m.Update(keyMsg("enter")) + m = updated.(Model) + + assert.Equal(t, "", m.CheckoutBranch(), "enter on current branch should not set checkout") + assert.Nil(t, cmd, "enter on current branch should not quit") +} + +func TestView_HeaderShownWhenTallEnough(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Simulate a tall and wide terminal + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "┌") + assert.Contains(t, view, "┘") + assert.Contains(t, view, "GitHub Stacks") + assert.Contains(t, view, "v0.0.1") + assert.Contains(t, view, "Base: main") + assert.Contains(t, view, "2 branches") + assert.Contains(t, view, "↑") + assert.Contains(t, view, "quit") +} + +func TestView_HeaderHiddenWhenShort(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk, "0.0.1") + + // Simulate a short terminal (below minHeightForHeader) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updated.(Model) + + view := m.View() + // Should NOT contain header box + assert.NotContains(t, view, "┌") + assert.NotContains(t, view, "GitHub Stacks") + // Should NOT contain help bar either (hints are only in header) + assert.NotContains(t, view, "commits") +} + +func TestView_HeaderHiddenWhenNarrow(t *testing.T) { + nodes := makeNodes("b1") + m := New(nodes, testTrunk, "0.0.1") + + // Tall but too narrow for header (below minWidthForHeader) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 35, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.NotContains(t, view, "┌") + assert.NotContains(t, view, "GitHub Stacks") +} + +func TestView_HeaderWithoutShortcutsWhenMediumWidth(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Wide enough for header but not for shortcuts (between minWidthForHeader and minWidthForShortcuts) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "┌", "header should be shown") + assert.Contains(t, view, "GitHub Stacks", "info should be shown") + assert.NotContains(t, view, "checkout", "shortcuts should be hidden at this width") +} + +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") + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, "3 branches (1 merged)") +} + +func TestView_BranchProgressIcon(t *testing.T) { + tests := []struct { + name string + merged []int // indices of merged branches + total int + wantIcon string + }{ + {"none merged", nil, 3, "○"}, + {"some merged", []int{0}, 3, "◐"}, + {"all merged", []int{0, 1, 2}, 3, "●"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + names := make([]string, tt.total) + for i := range names { + names[i] = fmt.Sprintf("b%d", i) + } + nodes := makeNodes(names...) + for _, idx := range tt.merged { + nodes[idx].Ref.PullRequest = &stack.PullRequestRef{Merged: true} + } + m := New(nodes, testTrunk, "0.0.1") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + view := m.View() + assert.Contains(t, view, tt.wantIcon) + }) + } +} + +func TestMouseClick_HeaderAreaIgnored(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m = updated.(Model) + + // Click inside the header area (row 5 is inside the 12-line header) + updated, _ = m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonLeft, + X: 10, + Y: 5, + }) + result := updated.(Model) + assert.Equal(t, 0, result.cursor, "clicking in header should not change cursor") +} + +func TestScrollClamp_CannotScrollPastContent(t *testing.T) { + nodes := makeNodes("b1", "b2") + m := New(nodes, testTrunk, "0.0.1") + + // Tall terminal with plenty of room for content + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + m = updated.(Model) + + // Scroll down many times — should not scroll past content + for i := 0; i < 50; i++ { + updated, _ = m.Update(tea.MouseMsg{ + Action: tea.MouseActionPress, + Button: tea.MouseButtonWheelDown, + }) + m = updated.(Model) + } + + // scrollOffset should be clamped (content fits in view, so offset stays 0) + view := m.View() + assert.Contains(t, view, "b1", "content should still be visible after excessive scrolling") +} diff --git a/internal/tui/stackview/styles.go b/internal/tui/stackview/styles.go new file mode 100644 index 0000000..a93ce04 --- /dev/null +++ b/internal/tui/stackview/styles.go @@ -0,0 +1,55 @@ +package stackview + +import "github.com/charmbracelet/lipgloss" + +var ( + // Branch name styles + currentBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan bold + normalBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + mergedBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + trunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) // gray italic + + // Focus indicator — reserved for future use + + // Status indicators + mergedIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Render("✓") // magenta + warningIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠") // yellow + openIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("○") // green + + // PR status + prOpenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + prMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta + prClosedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + prDraftStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + + // Diff stats + additionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + deletionsStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red + + // Commit lines + commitSHAStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow + commitSubjectStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + commitTimeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + + // Connector lines + connectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + connectorDashedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow (non-linear) + connectorFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white (focused) + connectorCurrentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan (current branch focused) + connectorMergedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // magenta (merged branch focused) + + // Dim text (separators, secondary labels) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + // Header styles + headerBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray box-drawing chars + headerTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) // white bold + headerInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan + headerInfoLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + headerShortcutKey = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // white + headerShortcutDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray + + // Expand/collapse toggle + expandedIcon = "▾" + collapsedIcon = "▸" +) diff --git a/main.go b/main.go index 5f07f5e..8e37389 100644 --- a/main.go +++ b/main.go @@ -1,26 +1,9 @@ package main import ( - "fmt" - - "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/cmd" ) func main() { - fmt.Println("hi world, this is the gh-stack extension!") - client, err := api.DefaultRESTClient() - if err != nil { - fmt.Println(err) - return - } - response := struct {Login string}{} - err = client.Get("user", &response) - if err != nil { - fmt.Println(err) - return - } - fmt.Printf("running as %s\n", response.Login) + cmd.Execute() } - -// For more examples of using go-gh, see: -// https://github.com/cli/go-gh/blob/trunk/example_gh_test.go diff --git a/skills/gh-stack/SKILL.md b/skills/gh-stack/SKILL.md new file mode 100644 index 0000000..66f665c --- /dev/null +++ b/skills/gh-stack/SKILL.md @@ -0,0 +1,729 @@ +--- +name: gh-stack +description: > + Manage stacked branches and pull requests with the gh-stack GitHub CLI extension. + Use when the user wants to create, push, rebase, sync, navigate, or view stacks of + dependent PRs. Triggers on tasks involving stacked diffs, dependent pull requests, + branch chains, or incremental code review workflows. +metadata: + author: github + version: "0.0.1" +--- + +# gh-stack + +`gh stack` is a [GitHub CLI](https://cli.github.com/) extension for managing **stacked branches and pull requests**. A stack is an ordered list of branches where each branch builds on the one below it, rooted on a trunk branch (typically the repo's default branch). Each branch maps to one PR whose base is the branch below it, so reviewers see only the diff for that layer. + +``` +main (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. + +## When to use this skill + +Use this skill when the user wants to: + +- Break a large change into a chain of small, reviewable PRs +- Create, rebase, push, or sync a stack of dependent branches +- Navigate between layers of a branch stack +- View the status of stacked PRs +- Clean up a stack after PRs are merged + +## Prerequisites + +The GitHub CLI (`gh`) v2.0+ must be installed and authenticated. Install the extension with: + +```bash +gh extension install github/gh-stack +``` + +## Agent rules + +1. **Always supply branch names as positional arguments** to `init`, `add`, and `checkout`. +2. **Always use `--auto` when pushing** to skip PR title prompts. +3. **Always use `--json` when viewing** to get structured output. +4. **Use `--remote ` when multiple remotes are configured**, or set `remote.pushDefault` in git config. +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. + +## Thinking about stack structure + +Each branch in a stack should represent a **discrete, logical unit of work** that can be reviewed independently. The changes within a branch should be cohesive—they belong together and make sense as a single PR. + +### Dependency chain + +Stacked branches form a dependency chain: each branch builds on the one below it. This means **foundational changes must go in lower (earlier) branches**, and code that depends on them goes in higher (later) branches. + +**Plan your layers before writing code.** For example, a full-stack feature might be structured like this (use branch names relevant to your actual task, not these generic ones): + +``` +main (trunk) + └── 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. + +### Staging changes deliberately + +Don't dump all changes into a single commit or branch. Stage changes in batches based on logical grouping: + +```bash +# 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" + +# Stage related migration +git add db/migrations/001_create_users.sql +git commit -m "Add user table migration" +``` + +Multiple commits per branch are fine and encouraged—they make the PR easier to review. The key is that all commits in a branch relate to the same logical concern. + +### When to create a new branch + +Create a new branch (`gh stack add`) when you're starting a **different concern** that depends on what you've built so far. Signs it's time for a new branch: + +- You're switching from backend to frontend work +- You're moving from core logic to tests or documentation +- The next set of changes has a different reviewer audience +- The current branch's PR is already large enough to review + +### One stack, one story + +Think of a stack from the reviewer's perspective: the stack of PRs should **tell a cohesive story** about a feature or project. A reviewer should be able to read the PRs in sequence and understand the progression of changes, with each PR being a small, logical piece of the whole. + +**When to use a single stack:** All the branches are part of the same feature, project, or closely related effort. Even if the work spans multiple concerns (models, API, frontend), they're all building toward the same goal. + +**When to create a separate stack:** The work is unrelated to your current stack — a different feature, a bug fix in an unrelated area, or an independent refactor. Don't mix unrelated work into a single stack just because you happen to be working on both. Start a new stack with `gh stack init` or switch to an existing stack with `gh stack checkout` for each distinct effort. + +Small, incidental fixes (e.g., fixing a typo you noticed) can go in the current stack if they're trivial. But if a change grows into its own project, it deserves its own stack. + +## Quick reference + +| Task | Command | +|------|---------| +| Create a stack | `gh stack init branch-a` | +| Create a stack with a prefix | `gh stack init -p feat auth` | +| Adopt existing branches | `gh stack init --adopt branch-a branch-b` | +| Set custom trunk | `gh stack init --base develop branch-a` | +| Add a branch to stack | `gh stack add branch-name` | +| Add branch + stage all + commit (shortcut) | `gh stack add -Am "message" new-branch` | +| Push + create PRs | `gh stack push --auto` | +| Push as drafts | `gh stack push --auto --draft` | +| Push without creating PRs | `gh stack push --skip-prs` | +| Push to specific remote | `gh stack push --auto --remote origin` | +| Sync (fetch, rebase, push) | `gh stack sync` | +| Sync with specific remote | `gh stack sync --remote origin` | +| Rebase entire stack | `gh stack rebase` | +| Rebase upstack only | `gh stack rebase --upstack` | +| Continue after conflict | `gh stack rebase --continue` | +| Abort rebase | `gh stack rebase --abort` | +| 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 PR | `gh stack checkout 42` | +| Check out by branch | `gh stack checkout feature-auth` | +| Remove stack | `gh stack unstack --local` | + +--- + +## Workflows + +### End-to-end: create a stack from scratch + +```bash +# 1. Initialize a stack with the first branch +gh stack init -p feat auth +# → creates feat/auth and checks it out + +# 2. Write code for the first layer (auth) +cat > auth.go << 'EOF' +package auth + +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // verify token + next.ServeHTTP(w, r) + }) +} +EOF + +# 3. Stage and commit using standard git commands +git add auth.go +git commit -m "Add auth middleware" + +# You can make multiple commits on the same branch +cat > auth_test.go << 'EOF' +package auth + +func TestMiddleware(t *testing.T) { + // test auth middleware +} +EOF +git add auth_test.go +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 (prefixed), checks it out + +# 5. Write code for the API layer +cat > api.go << 'EOF' +package api + +func RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/users", handleUsers) +} +EOF +git add api.go +git commit -m "Add API routes" + +# 6. Add a third layer for frontend +gh stack add frontend +# → creates feat/frontend, checks it out + +cat > frontend.go << 'EOF' +package frontend + +func RenderDashboard(w http.ResponseWriter) { + // calls the API endpoints from the layer below +} +EOF +git add frontend.go +git commit -m "Add frontend dashboard" + +# ── Stack complete: feat/auth → feat/api-routes → feat/frontend ── + +# 7. Push everything and create draft PRs +gh stack push --auto --draft + +# 8. Verify the stack +gh stack view --json +``` + +> **Shortcut:** If you prefer a faster flow, `gh stack add -Am "message" branch-name` combines staging, committing, and branch creation into one command. This is useful for single-commit layers but bypasses deliberate staging. + +### Making mid-stack changes + +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 + +# 1. Navigate to the API branch +gh stack down +# or: gh stack checkout feat/api-routes + +# 2. Make the change where it belongs +cat > users_api.go << 'EOF' +package api + +func handleGetUser(w http.ResponseWriter, r *http.Request) { + // new endpoint the frontend needs +} +EOF +git add users_api.go +git commit -m "Add get-user endpoint" + +# 3. Rebase everything above to pick up the change +gh stack rebase --upstack + +# 4. Navigate back to where you were working +gh stack top +# or: gh stack checkout feat/frontend + +# 5. Continue working — the API changes are now available +``` + +**Why this matters:** If you make API changes on the frontend branch, those changes will end up in the wrong PR. The API PR won't include them, and the frontend PR will have unrelated API diffs mixed in. Always put changes in the branch where they logically belong. + +### Modify a mid-stack branch and sync + +When you need to revisit a branch after the initial creation (e.g., responding to review feedback): + +```bash +# 1. Navigate to the branch that needs changes +gh stack bottom +# or: gh stack checkout feat/auth +# or: gh stack checkout 42 (by PR number) + +# 2. Make changes and commit +cat > auth.go << 'EOF' +package auth +// updated implementation +EOF +git add auth.go +git commit -m "Fix auth token validation" + +# 3. Rebase everything above this branch +gh stack rebase --upstack + +# 4. Push the updated stack +gh stack push --auto +``` + +### Routine sync after merges + +```bash +# Single command: fetch, rebase, push, sync PR state +gh stack sync +``` + +### 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: +gh stack sync +# → fetches latest, detects the merge, fast-forwards trunk +# → rebases feat/api-routes onto updated trunk using --onto (skips merged branch) +# → rebases feat/api-tests onto feat/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/api-tests 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. + +### Handle rebase conflicts (agent workflow) + +```bash +# 1. Start the rebase +gh stack rebase + +# 2. If exit code 3 (conflict): +# - Parse stderr for conflicted file paths +# - Read those files to find <<<<<<< / ======= / >>>>>>> markers +# - Edit files to resolve conflicts +# - Stage resolved files: +git add path/to/resolved-file.go + +# 3. Continue the rebase +gh stack rebase --continue + +# 4. If another conflict occurs, repeat steps 2-3 + +# 5. If unable to resolve, abort to restore everything +gh stack rebase --abort +``` + +### Parsing `--json` output + +```bash +# Get stack state as JSON +output=$(gh stack view --json) + +# Check if any branch needs a rebase, and rebase if so +needs_rebase=$(echo "$output" | jq '[.branches[] | select(.needsRebase == true)] | length') +if [ "$needs_rebase" -gt 0 ]; then + echo "Branches need rebase, rebasing stack..." + gh stack rebase +fi + +# Get all open PR URLs +echo "$output" | jq -r '.branches[] | select(.pr.state == "OPEN") | .pr.url' + +# Find merged branches +echo "$output" | jq -r '.branches[] | select(.isMerged == true) | .name' + +# Get the current branch +echo "$output" | jq -r '.currentBranch' + +# Check if the stack is fully merged (all branches merged) +echo "$output" | jq '[.branches[] | .isMerged] | all' +``` + +### Clean up after all PRs are merged + +```bash +gh stack unstack --local +``` + +--- + +## Commands + +### Initialize a stack — `gh stack init` + +Creates a new stack. Provide branch names as positional arguments. + +``` +gh stack init [branches...] [flags] +``` + +```bash +# Create a stack with new branches (branched from trunk) +gh stack init branch-a branch-b branch-c + +# Use a different trunk branch +gh stack init --base develop branch-a branch-b + +# Adopt existing branches into a stack +gh stack init --adopt branch-a branch-b branch-c + +# Set a branch prefix (branch names you provide are automatically prefixed) +gh stack init -p feat auth +# → creates feat/auth +``` + +| Flag | Description | +|------|-------------| +| `-b, --base ` | Trunk branch (defaults to the repo's default branch) | +| `-a, --adopt` | Adopt existing branches instead of creating new ones | +| `-p, --prefix ` | Set a branch name prefix for auto-generated names | + +**Behavior:** + +- Creates any branches that don't already exist (branching from the trunk branch) +- In `--adopt` mode: validates all branches exist, rejects if any is already in a stack or has an existing PR +- Checks out the last branch in the list +- Enables `git rerere` so conflict resolutions are remembered across rebases + +--- + +### Add a branch — `gh stack add` + +Add a new branch on top of the current stack. Must be run while on the topmost branch (or the trunk if the stack has no branches yet). Always provide an explicit branch name. + +``` +gh stack add [branch] [flags] +``` + +**Recommended workflow — create the branch, then use standard git:** + +```bash +# Create a new branch and switch to it +gh stack add api-routes + +# Write code, stage deliberately, and commit +git add internal/api/routes.go internal/api/handlers.go +git commit -m "Add user API routes" + +# Make more commits on the same branch as needed +git add internal/api/middleware.go +git commit -m "Add rate limiting middleware" +``` + +**Shortcut — stage, commit, and branch in one command:** + +```bash +# Create a new branch, stage all changes, and commit +gh stack add -Am "Add API routes" api-routes + +# Create a new branch, stage tracked files only, and commit +gh stack add -um "Fix auth bug" auth-fix +``` + +| Flag | Description | +|------|-------------| +| `-m, --message ` | Create a commit with this message | +| `-A, --all` | Stage all changes including untracked files (requires `-m`) | +| `-u, --update` | Stage tracked files only (requires `-m`) | + +**Behavior notes:** + +- `-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. +- If a prefix was set during `init`, the prefix is applied to branch names: `prefix/branch-name`. +- 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. + +--- + +### Push branches and create PRs — `gh stack push` + +Push all stack branches and create/update PRs. + +``` +gh stack push [flags] +``` + +```bash +# Push and auto-title new PRs +gh stack push --auto + +# Push and create PRs as drafts +gh stack push --auto --draft + +# Push branches only, no PR creation +gh stack push --skip-prs +``` + +| Flag | Description | +|------|-------------| +| `--auto` | Auto-generate PR titles without prompting | +| `--draft` | Create new PRs as drafts | +| `--skip-prs` | Push branches without creating or updating PRs | +| `--remote ` | Remote to push to (use if multiple remotes exist) | + +**Behavior:** + +- Pushes all active (non-merged) branches atomically (`--force-with-lease --atomic`) +- Creates a new PR for each branch that doesn't have one (base set to the first non-merged ancestor branch) +- Syncs PR metadata for branches that already have PRs + +**PR title auto-generation (`--auto`):** + +- Single commit on branch → uses the commit subject as the PR title, commit body as PR body +- Multiple commits on branch → humanizes the branch name (hyphens/underscores → spaces) as the title + +**Output (stderr):** + +- `Created PR #N for ` for each newly created PR +- `PR #N for is up to date` for existing PRs +- `Pushed and synced N branches` summary + +--- + +### Sync the stack — `gh stack sync` + +Fetch, rebase, push, and sync PR state in a single command. This is the recommended command for routine synchronization. + +``` +gh stack sync [flags] +``` + +| Flag | Description | +|------|-------------| +| `--remote ` | Remote to fetch from and push to (use if multiple remotes exist) | + +**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 squash-merged PRs automatically with `--onto`. 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 + +**Output (stderr):** + +- `✓ Fetched latest changes from origin` +- `✓ Trunk main fast-forwarded to ` or `✓ Trunk main is already up to date` +- `✓ Rebased onto ` per branch (if base moved) +- `✓ Pushed N branches` +- `✓ PR #N () — Open` per branch +- `Merged: #N, #M` for merged branches +- `✓ Stack synced` + +--- + +### Rebase the stack — `gh stack rebase` + +Pull from remote and cascade-rebase stack branches. Use this when `sync` reports a conflict or when you need finer control (e.g., rebase only part of the stack). + +``` +gh stack rebase [branch] [flags] +``` + +```bash +# Rebase the entire stack +gh stack rebase + +# Rebase only branches from trunk to current branch +gh stack rebase --downstack + +# Rebase only branches from current branch to top +gh stack rebase --upstack + +# After resolving a conflict: stage files with `git add`, then: +gh stack rebase --continue + +# Abort and restore all branches to pre-rebase state +gh stack rebase --abort +``` + +| Flag | Description | +|------|-------------| +| `--downstack` | Only rebase branches from trunk to the current branch | +| `--upstack` | Only rebase branches from the current branch to the top | +| `--continue` | Continue after resolving conflicts | +| `--abort` | Abort and restore all branches | +| `--remote ` | Remote to fetch from (use if multiple remotes exist) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | Target branch (defaults to the current branch) | + +**Conflict handling:** See [Handle rebase conflicts](#handle-rebase-conflicts-agent-workflow) in the Workflows section for the full resolution workflow. + +**Squash-merge detection:** If a branch's PR was squash-merged on GitHub, the rebase automatically uses `git rebase --onto` to correctly replay commits on top of the merge target. This is handled transparently. + +**Rerere (conflict memory):** `git rerere` is enabled by `init` so previously resolved conflicts are auto-resolved in future rebases. + +--- + +### View the stack — `gh stack view` + +Display the current stack's branches, PR status, and recent commits. Use `--json` for structured output. + +``` +gh stack view [flags] +``` + +```bash +# Structured JSON output (recommended) +gh stack view --json +``` + +| Flag | Description | +|------|-------------| +| `--json` | Output stack data as JSON to stdout | + +**`--json` output format:** + +```json +{ + "trunk": "main", + "prefix": "feat", + "currentBranch": "feat/api-routes", + "branches": [ + { + "name": "feat/auth", + "head": "abc1234...", + "base": "def5678...", + "isCurrent": false, + "isMerged": true, + "needsRebase": false, + "pr": { + "number": 42, + "url": "https://github.com/owner/repo/pull/42", + "state": "MERGED" + } + }, + { + "name": "feat/api-routes", + "head": "789abcd...", + "base": "abc1234...", + "isCurrent": true, + "isMerged": false, + "needsRebase": false, + "pr": { + "number": 43, + "url": "https://github.com/owner/repo/pull/43", + "state": "OPEN" + } + } + ] +} +``` + +Fields per branch: +- `name` — branch name +- `head` — current HEAD SHA +- `base` — parent branch's HEAD SHA at last sync +- `isCurrent` — whether this is the checked-out branch +- `isMerged` — whether the PR has been merged +- `needsRebase` — whether the base branch is not an ancestor (non-linear history) +- `pr` — PR metadata (omitted if no PR exists). `state` is `"OPEN"` or `"MERGED"`. + +> **Note:** `--short` outputs a compact text view with box-drawing characters and status icons. Prefer `--json` for programmatic use. + +--- + +### Navigate the stack + +Move between branches without remembering branch names. These commands are fully non-interactive. + +```bash +gh stack up # Move up one branch (further from trunk) +gh stack up 3 # Move up three branches +gh stack down # Move down one branch (closer to trunk) +gh stack down 2 # Move down two branches +gh stack top # Jump to the top of the stack (furthest from trunk) +gh stack bottom # Jump to the bottom (first non-merged branch above trunk) +``` + +Navigation clamps to stack bounds. Merged branches are skipped when navigating from active branches. + +--- + +### Check out a stack — `gh stack checkout` + +Check out a locally tracked stack by PR number or branch name. Always provide the target as an argument. + +``` +gh stack checkout +``` + +```bash +# By PR number +gh stack checkout 42 + +# By branch name +gh stack checkout feature-auth +``` + +Resolves the target against locally tracked stacks. Accepts a PR number, PR URL, or branch name. Checks out the matching branch. + +> **Note:** This command only works with stacks that have been created locally (via `gh stack init`). Server-side stack discovery is not yet implemented. + +--- + +### Remove a stack — `gh stack unstack` + +Remove a stack from local tracking. Use `--local` to avoid warnings about unsupported server-side deletion. + +``` +gh stack unstack [branch] [flags] +``` + +```bash +# Remove from local tracking +gh stack unstack --local + +# Specify a branch to identify which stack +gh stack unstack feature-auth --local +``` + +| Flag | Description | +|------|-------------| +| `--local` | Only delete the stack locally (recommended) | + +| Argument | Description | +|----------|-------------| +| `[branch]` | A branch in the stack (defaults to the current branch) | + +--- + +## Output conventions + +- **Status messages** go to **stderr** with emoji prefixes: `✓` (success), `✗` (error), `⚠` (warning), `ℹ` (info). +- **Data output** (e.g., `view --json`) goes to **stdout**. +- When piping output, use `2>/dev/null` to suppress status messages if only data output is needed. + +## Exit codes and error recovery + +| Code | Meaning | Agent action | +|------|---------|-------------| +| 0 | Success | Proceed normally | +| 1 | Generic error | Read stderr for details; may indicate commit/push failure | +| 2 | Not in a stack | Run `gh stack init` to create a stack first | +| 3 | Rebase conflict | Parse stderr for conflicted file paths, resolve conflicts, run `gh stack rebase --continue` | +| 4 | GitHub API failure | Check `gh auth status`, retry the command | +| 5 | Invalid arguments | Fix the command invocation (check flags and arguments) | +| 6 | Disambiguation required | A branch belongs to multiple stacks. Run `gh stack checkout ` to switch to a non-shared branch first | +| 7 | Rebase already in progress | Run `gh stack rebase --continue` (after resolving conflicts) or `gh stack rebase --abort` to start over | + +## Known limitations + +1. **Stacks are strictly linear.** Branching stacks (multiple children on a single parent) are not supported. Each branch has exactly one parent and at most one child. If you need parallel workstreams, use separate stacks. +2. **Stack disambiguation cannot be bypassed.** If the current branch is the trunk of multiple stacks, commands error with code 6. Check out a non-shared branch first. +3. **Multiple remotes require `--remote` or config.** If more than one remote is configured, pass `--remote ` or set `remote.pushDefault` in git config before running `push`, `sync`, or `rebase`. +4. **Merging PRs:** Merging Stacked PRs from the CLI is not supported yet. Direct users to open the PR URL in a browser to merge PRs. +5. **Server-side stack deletion is not supported.** Use `unstack --local`. +6. **Server-side stack discovery is not supported.** `checkout` only works with locally tracked stacks. +7. **PR title and body are auto-generated.** There is no flag to set a custom PR title or body during `push`. The title and body are generated from commit messages plus a footer. Use `gh pr edit` to modify PR title and body after creation.