From 0b8abf7a3f03a374a48c7eed94f4501850a7111e Mon Sep 17 00:00:00 2001 From: StillAyo <52698719+StillAyo@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:01:08 +0100 Subject: [PATCH 1/2] Add a leased, atomic git push Push force pushes a set of branches in one atomic git push, each with --force-with-lease against the remote tip the caller expects, so a remote that moved since the last fetch refuses the whole push. --- pkg/git/refs_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ pkg/git/remote.go | 31 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/pkg/git/refs_test.go b/pkg/git/refs_test.go index 51a5bbe..8a9a5c3 100644 --- a/pkg/git/refs_test.go +++ b/pkg/git/refs_test.go @@ -227,3 +227,49 @@ func TestReflog(t *testing.T) { t.Errorf("missing ref: %v %v", got, err) } } + +func TestPush(t *testing.T) { + c, repo, dir := remoteFixture(t) + ctx := context.Background() + remoteHead := func(branch string) string { + out, _ := gittest.RunErr(t, dir, "ls-remote", "origin", "refs/heads/"+branch) + sha, _, _ := strings.Cut(out, "\t") + return sha + } + + // A new branch is leased against not existing yet. + gittest.Run(t, dir, "switch", "-q", "-c", "a") + a1 := gittest.Commit(t, dir, "a.txt", "a", "a") + if err := c.Push(ctx, repo, "origin", []git.PushLease{{Branch: "a"}}); err != nil { + t.Fatal(err) + } + if got := remoteHead("a"); got != a1 { + t.Fatalf("remote a = %q, want %s", got, a1) + } + + // A rewritten branch is force pushed over the tip it was leased against. + gittest.Run(t, dir, "commit", "-q", "--amend", "-m", "a amended") + a2 := gittest.Run(t, dir, "rev-parse", "HEAD") + if err := c.Push(ctx, repo, "origin", []git.PushLease{{Branch: "a", Expect: a1}}); err != nil { + t.Fatal(err) + } + if got := remoteHead("a"); got != a2 { + t.Fatalf("remote a = %q, want %s", got, a2) + } + + // Someone else moves a; the lease on the tip we saw refuses every branch + // in the push, not just a. + gittest.Run(t, dir, "fetch", "-q", "origin") + pushRemote(t, dir, "a", "theirs.txt") + gittest.Run(t, dir, "switch", "-q", "-c", "b") + gittest.Commit(t, dir, "b.txt", "b", "b") + gittest.Run(t, dir, "switch", "-q", "a") + gittest.Commit(t, dir, "a2.txt", "a2", "a again") + err := c.Push(ctx, repo, "origin", []git.PushLease{{Branch: "a", Expect: a2}, {Branch: "b"}}) + if err == nil { + t.Fatal("a stale lease must refuse the push") + } + if got := remoteHead("b"); got != "" { + t.Errorf("b was pushed despite the refusal: %s", got) + } +} diff --git a/pkg/git/remote.go b/pkg/git/remote.go index e3e276b..0e4c96c 100644 --- a/pkg/git/remote.go +++ b/pkg/git/remote.go @@ -30,3 +30,34 @@ func (c *Client) RemoteFor(ctx context.Context, repo Repo, branch string) string } return "origin" } + +// PushLease is a branch to push and the remote tip it may replace. An empty +// Expect means the branch must not exist on the remote yet. +type PushLease struct { + Branch string + Expect string +} + +// Push force pushes each branch to the same name on remote, all or nothing. +// Each one is leased against its Expect, so a remote that has moved since +// the caller looked is refused rather than overwritten. +func (c *Client) Push(ctx context.Context, repo Repo, remote string, leases []PushLease) error { + if len(leases) == 0 { + return nil + } + args := []string{"push", "--atomic", "--quiet"} + refspecs := make([]string, 0, len(leases)) + for _, l := range leases { + ref := "refs/heads/" + l.Branch + args = append(args, "--force-with-lease="+ref+":"+l.Expect) + refspecs = append(refspecs, ref+":"+ref) + } + args = append(append(args, remote), refspecs...) + if _, err := c.gitIn(ctx, repo, args...); err != nil { + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + return fmt.Errorf("push to %s: %s", remote, ee.Result.Err()) + } + return err + } + return nil +} From e1a71bbb16586823e422c90df976ddfb51b04b51 Mon Sep 17 00:00:00 2001 From: StillAyo <52698719+StillAyo@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:01:08 +0100 Subject: [PATCH 2/2] Add submit --down to submit only up to a branch git stack submit --down pushes the current branch and every branch below it, and leaves the ones above alone, so the bottom of a stack can go for review while the top is still moving. Naming a branch does the same up to that branch. A plain submit still pushes the whole stack. gh stack submit always pushes every branch, so the whole stack still goes through it. With --down and branches above, submit pushes those branches itself in one atomic, leased git push, creates their missing PRs through the forge, and runs gh stack link with the PR numbers so they join the stack on GitHub without pushing anything else. gh stack's editor only exists inside its submit, so new PRs there take their titles from their commits unless --ai or supplied texts say otherwise. stack_submit gets matching down and branch inputs. --- README.md | 10 +- cmd/submit.go | 37 ++++-- docs/architecture.md | 13 ++ docs/mcp.md | 2 +- pkg/app/submit.go | 202 ++++++++++++++++++++++++----- pkg/app/submit_test.go | 140 +++++++++++++++++++- pkg/backend/ghstack/submit_test.go | 22 ++++ pkg/backend/ghstack/submitter.go | 20 +++ pkg/mcp/instructions.go | 2 +- pkg/mcp/tools.go | 5 +- pkg/stack/ports.go | 14 ++ pkg/stack/stacktest/backend.go | 9 ++ 12 files changed, 426 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 22563bd..b429ce9 100644 --- a/README.md +++ b/README.md @@ -303,12 +303,17 @@ Flags: > Aliases: `git submit`, `git ss` Pushes every branch in the stack and creates or updates a PR for each one, each based on the branch -below it. New PRs are ready for review by default. +below it. `--down` stops at the current branch, and `git stack submit ` at the named one, +leaving the branches above alone; handy for getting the bottom of a stack reviewed while you keep +working on the top. New PRs are ready for review by default. Unless you pass `-n` / `--no-edit`, `--ai` or `--no-interactive`, gh stack opens its editor for the title and body of each new PR. `--draft`, `--publish`, `--ai` and the editor only apply to new PRs. +gh stack can only submit a whole stack, so with `--down` git-stack pushes those branches itself, +creates their missing PRs and has `gh stack link` add them to the stack on GitHub; the editor isn't +available there, so new PRs take their titles from their commits unless you pass `--ai`. -gh stack does the push, with `--force-with-lease`, so after a restack your branches are force +Branches are pushed with `--force-with-lease`, so after a restack your branches are force pushed. If a branch has commits on the remote that aren't in your local one (someone else pushed to it), submit refuses rather than overwrite them. `git sync` pulls them in when the remote is simply ahead; otherwise merge or cherry-pick them by hand, or pass `--force` if you really do want @@ -324,6 +329,7 @@ Flags: - `--no-ai` don't use AI, even if `stack.ai.auto` is set - `-f` / `--force` push even if it overwrites commits someone else pushed - `--dry-run` show what would be submitted without pushing anything +- `--down` only submit the current branch (or the one you name) and the ones below it ### `git stack up` / `git stack down` / `git stack top` / `git stack bottom` diff --git a/cmd/submit.go b/cmd/submit.go index 152dfbd..f53339a 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -18,21 +18,26 @@ func newSubmitCmd(c *cli) *cobra.Command { useAI bool noAI bool force bool + down bool ) cmd := &cobra.Command{ - Use: "submit", + Use: "submit [branch]", Aliases: []string{"ss"}, Short: "Push the stack and create or update its pull requests", Long: `Pushes every branch in the stack and creates or updates a PR for each one, each -based on the branch below it. New PRs are ready for review by default; pass ---draft, or set git config stack.submit.default to draft (or ask, to be asked -each time). +based on the branch below it. With --down, or a branch name, it stops at the +current (or named) branch and leaves the ones above it alone. New PRs are ready +for review by default; pass --draft, or set git config stack.submit.default to +draft (or ask, to be asked each time). Unless you pass --no-edit, --ai or --no-interactive, gh stack opens its editor for the title and body of each new PR. --draft, --publish, --ai and the editor -only apply to new PRs. +only apply to new PRs. gh stack can only submit a whole stack, so with --down +we push those branches ourselves and have gh stack link add their PRs to the +stack; the editor isn't available there, so new PRs take their titles from +their commits unless you pass --ai. -gh stack does the push, with --force-with-lease, so after a restack your +Branches are pushed with --force-with-lease, so after a restack your branches are force pushed. If a branch has commits on the remote that aren't in your local one (someone else pushed to it), submit refuses rather than overwrite them. Run git stack sync to pull them in, or pass --force if you @@ -40,13 +45,19 @@ really do want them gone.`, Example: ` # push the stack and open PRs for any new branches git ss + # push only the current branch and the ones below it + git ss --down + + # push feat-b and the ones below it, whichever branch is checked out + git ss feat-b + # open the new PRs as drafts, with titles taken from the commits git ss -d -n # see what would be created without pushing anything git stack submit --dry-run`, - Args: cobra.NoArgs, - ValidArgsFunction: completeNothing, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: c.completeBranches, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() a, repo, err := c.app(ctx) @@ -58,14 +69,19 @@ really do want them gone.`, if err != nil { return err } - res, err := a.Submit(ctx, repo, app.SubmitOptions{ + opts := app.SubmitOptions{ Draft: draft, Publish: publish, NoEdit: noEdit || (!edit && c.globals.NoInteractive), DryRun: dryRun, UseAI: ai, Force: force, - }) + Down: down, + } + if len(args) == 1 { + opts.Branch = args[0] + } + res, err := a.Submit(ctx, repo, opts) if err != nil { return err } @@ -118,6 +134,7 @@ really do want them gone.`, must(cmd.Flags().MarkHidden("stack")) cmd.Flags().BoolVarP(&force, "force", "f", false, "push even if it overwrites commits someone else pushed") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report what would be submitted without pushing") + cmd.Flags().BoolVar(&down, "down", false, "only submit the current branch and the ones below it") cmd.Flags().BoolVar(&useAI, "ai", false, "have Claude Code write titles and bodies for new PRs (see stack.ai.auto)") cmd.Flags().BoolVar(&noAI, "no-ai", false, "never use AI; takes precedence over --ai and stack.ai.auto") return cmd diff --git a/docs/architecture.md b/docs/architecture.md index 2f5fd00..ba3b55c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -214,6 +214,19 @@ absorbed hunks simply drop out of the staged diff. Everything above the current through modify's upstack restack, conflict handling included. There is no absorb specific abort; the old tips are printed and in the reflog. +**`submit --down` pushes and creates PRs itself, then links them.** `--down`, or naming a branch, +submits that branch and the ones below it, so the bottom of a stack can go for review while the +top is still moving. gh stack submit always pushes every branch, so when there is a branch above +the one asked for, submit does the work itself: it pushes those branches in one atomic +`git push`, each with `--force-with-lease` against its remote tracking tip, creates any missing PRs +through the forge (with the supplied or drafted text, otherwise a single commit's subject and body, +the way gh stack fills them in), and runs `gh stack link` with the PR numbers so they join the +stack on GitHub. Numbers resolve to PRs rather than branches, so link pushes nothing. With no stack +on GitHub yet and a single PR there is nothing to link. A failed link is only a notice: the PRs +exist, and gh stack submit finds open PRs by branch and adopts a stack that already exists, so a +later whole stack submit tidies up. gh stack's editor only exists inside its submit, so with +`--down` the titles come from the commits and we say so. + **`submit --ai` drafts first, then lets gh stack submit, then fixes the PRs up.** The alternative was to push and create PRs ourselves and only use gh stack to link them. We didn't, because gh stack owns the push (`--force-with-lease`), the base calculation, the diff --git a/docs/mcp.md b/docs/mcp.md index 45e1f6e..14fb6e3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -27,7 +27,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no | `stack_abort` | Give up an interrupted restack and put every moved branch back | | destructive | | `stack_restack` | Local rebase of the stack onto its parents, bottom branch onto the local trunk included; only checks out a branch to resolve a conflict through one git rebase | `scope` = `all`/`upstack`/`downstack`/`only`, `branch`, `continue`, `abort`, `stage_all` | destructive | | `stack_navigate` | `up`/`down`/`top`/`bottom` with `steps`, or `branch` | `direction`, `steps`, `branch` | idempotent | -| `stack_submit` | Push and create/update chained PRs; ready for review by default | `draft`, `publish`, `dry_run`, `pull_requests` `{branch: {title, body}}`, `use_ai`, `force` | destructive, open-world | +| `stack_submit` | Push and create/update chained PRs (with `down`, only up to the current branch); ready for review by default | `down`, `branch`, `draft`, `publish`, `dry_run`, `pull_requests` `{branch: {title, body}}`, `use_ai`, `force` | destructive, open-world | | `stack_sync` | Fetch, update trunk and restack every stack (checked out or not, all worktrees), never pushing; merged branches deleted per `stack.sync.prune` | `prune` (force deletion) | destructive, open-world | | `stack_merge` | Merge the stack's PRs up to a branch into trunk, all or nothing, then sync; refuses while a check on any of them failed or is still running | `branch` (default current), `method` = `merge`/`squash`/`rebase`, `no_sync`, `force` (skip the checks check; only with the user's say so) | destructive, open-world | diff --git a/pkg/app/submit.go b/pkg/app/submit.go index c7b9698..4bb92a4 100644 --- a/pkg/app/submit.go +++ b/pkg/app/submit.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "strings" "github.com/DomBlack/git-stack/pkg/ai" "github.com/DomBlack/git-stack/pkg/config" @@ -37,6 +39,12 @@ type SubmitOptions struct { // Force pushes even when a branch has commits on the remote that aren't // in the local branch, overwriting them. Force bool + // Down submits only the current branch and the ones below it, leaving + // the ones above alone. + Down bool + // Branch submits that branch and the ones below it instead of the + // current branch; it implies Down. + Branch string } // SubmittedPR is one line of the result. @@ -67,32 +75,46 @@ type SubmitResult struct { } // Submit pushes the current stack and creates or updates its pull requests. +// With Down or Branch it stops at a branch; when branches above it are left +// out, submitPart does the work instead of gh stack submit. func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (SubmitResult, error) { if a.d.Submit == nil { return SubmitResult{}, stack.New(stack.KindUnsupported, "no submit backend configured") } - current, err := a.d.Git.CurrentBranch(ctx, repo) - if err != nil { - if errors.Is(err, git.ErrDetached) { - return SubmitResult{}, stack.New(stack.KindInvalidArgs, "HEAD is detached; check out a stacked branch first") + target := o.Branch + if target == "" { + cur, err := a.d.Git.CurrentBranch(ctx, repo) + if err != nil { + if errors.Is(err, git.ErrDetached) { + return SubmitResult{}, stack.New(stack.KindInvalidArgs, "HEAD is detached; check out a stacked branch or name one") + } + return SubmitResult{}, err } - return SubmitResult{}, err + target = cur } graph, err := a.d.Meta.Load(ctx, repo) if err != nil { return SubmitResult{}, err } - s, _, ok := graph.StackOf(current) + s, idx, ok := graph.StackOf(target) if !ok { - if graph.IsTrunk(current) { - return SubmitResult{}, stack.Newf(stack.KindNotInStack, "%s is a trunk; check out a stacked branch to submit its stack", current) + if graph.IsTrunk(target) { + return SubmitResult{}, stack.Newf(stack.KindNotInStack, "%s is a trunk; check out a stacked branch or name one to submit", target) } - return SubmitResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", current) + return SubmitResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", target) + } + scope := s.Branches + if o.Down || o.Branch != "" { + scope = s.Branches[:idx+1] + } + partial := slices.ContainsFunc(s.Branches[len(scope):], func(b stack.Branch) bool { return !b.Merged() }) + if !slices.ContainsFunc(scope, func(b stack.Branch) bool { return !b.Merged() }) { + return SubmitResult{}, stack.Newf(stack.KindInvalidArgs, "nothing to submit up to %s; its pull request has merged", target) } res := SubmitResult{Stack: s.Names(), DryRun: o.DryRun} if !o.Force { var live []string - for _, b := range s.Branches { + for _, b := range scope { if !b.Merged() { live = append(live, b.Name) } @@ -109,7 +131,7 @@ func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (Submi } before := PRsFor(known) var newBranches []string - for _, b := range s.Branches { + for _, b := range scope { if b.Merged() { continue } @@ -153,25 +175,34 @@ func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (Submi } } - // Route (b): let gh stack push and create everything, then set texts. - interactive := !o.NoEdit && len(texts) == 0 && a.d.Prompter != nil && len(newBranches) > 0 - var out stack.SubmitResult - submit := func(ctx context.Context) error { - var err error - out, err = a.d.Submit.Submit(ctx, repo, stack.SubmitOptions{Publish: !draft, Interactive: interactive}) - return err - } - if interactive { - // gh stack's editor owns the terminal; no headline over it. - err = submit(ctx) + editor := !o.NoEdit && len(texts) == 0 && a.d.Prompter != nil && len(newBranches) > 0 + if partial { + if editor { + res.Notices = append(res.Notices, "gh stack's editor only opens for a whole stack submit; new PRs take their titles from their commits") + } + if err := a.submitPart(ctx, repo, s, scope, texts, draft, &res); err != nil { + return SubmitResult{}, err + } } else { - err = a.progress(ctx, PhaseSubmit, "Submitting stack", submit) - } - if err != nil { - return SubmitResult{}, err - } - if !out.Streamed { - res.Output = out.Output + // Route (b): let gh stack push and create everything, then set texts. + var out stack.SubmitResult + submit := func(ctx context.Context) error { + var err error + out, err = a.d.Submit.Submit(ctx, repo, stack.SubmitOptions{Publish: !draft, Interactive: editor}) + return err + } + if editor { + // gh stack's editor owns the terminal; no headline over it. + err = submit(ctx) + } else { + err = a.progress(ctx, PhaseSubmit, "Submitting stack", submit) + } + if err != nil { + return SubmitResult{}, err + } + if !out.Streamed { + res.Output = out.Output + } } // The cache was refreshed moments ago, so ask the forge again explicitly. @@ -191,10 +222,11 @@ func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (Submi continue } e.Number, e.URL, e.State = pr.Number, pr.URL, pr.State - e.Created = e.WouldCreate - e.WouldCreate = false + if e.WouldCreate { + e.Created, e.WouldCreate = true, false + } text, hasText := texts[e.Branch] - if !hasText || a.d.Forge == nil { + if !hasText || e.TextUpdated || a.d.Forge == nil { continue } title, body := text.Title, text.Body @@ -211,7 +243,14 @@ func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (Submi // append while the chain is broken by a wrong base. Now that the base // is right, run the backend once more so it gets another go; it pushes // nothing new and only re-links. - if _, err := a.d.Submit.Submit(ctx, repo, stack.SubmitOptions{Publish: !draft}); err != nil { + relink := func() error { + _, err := a.d.Submit.Submit(ctx, repo, stack.SubmitOptions{Publish: !draft}) + return err + } + if partial { + relink = func() error { return a.linkPart(ctx, repo, s, res.PullRequests, &res) } + } + if err := relink(); err != nil { res.Notices = append(res.Notices, fmt.Sprintf("could not re-link the stack on GitHub after fixing bases: %v", err)) } } @@ -224,6 +263,103 @@ func (a *App) Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (Submi return res, nil } +// submitPart submits only the branches in scope, a run of the stack from +// the bottom up. gh stack submit always pushes every branch, so this pushes +// the branches itself, creates their missing pull requests through the forge, +// and has gh stack link add the pull requests to the stack on GitHub. +func (a *App) submitPart(ctx context.Context, repo git.Repo, s *stack.Stack, scope []stack.Branch, texts map[string]PRText, draft bool, res *SubmitResult) error { + if a.d.Forge == nil { + return ErrNoForge + } + var live []string + for _, b := range scope { + if !b.Merged() { + live = append(live, b.Name) + } + } + top := live[len(live)-1] + err := a.progress(ctx, PhaseSubmit, fmt.Sprintf("Submitting %s and the branches below it", top), func(ctx context.Context) error { + leases := map[string][]git.PushLease{} + var remotes []string + for _, name := range live { + remote, tip, _, err := a.remoteTip(ctx, repo, name) + if err != nil { + return err + } + if _, seen := leases[remote]; !seen { + remotes = append(remotes, remote) + } + leases[remote] = append(leases[remote], git.PushLease{Branch: name, Expect: tip}) + } + for _, remote := range remotes { + if err := a.d.Git.Push(ctx, repo, remote, leases[remote]); err != nil { + return stack.Newf(stack.KindInvalidArgs, "%v", err). + WithSteps("run git stack sync to pull in what changed on the remote, then submit again") + } + } + + parent := s.Trunk + for _, name := range live { + i := slices.IndexFunc(res.PullRequests, func(e SubmittedPR) bool { return e.Branch == name }) + e := &res.PullRequests[i] + if e.WouldCreate { + text, hasText := texts[name] + if !hasText { + text = a.defaultPRText(ctx, repo, parent, name) + } + pr, err := a.d.Forge.CreatePR(ctx, repo, forge.CreatePR{Head: name, Base: parent, Title: text.Title, Body: text.Body, Draft: draft}) + if err != nil { + return err + } + e.Number, e.URL, e.State = pr.Number, pr.URL, pr.State + e.Created, e.WouldCreate, e.TextUpdated = true, false, hasText + } + parent = name + } + return a.linkPart(ctx, repo, s, res.PullRequests, res) + }) + return err +} + +// linkPart adds the submitted pull requests to the stack on GitHub. A single +// pull request with no stack yet has nothing to join. A failure is only a +// notice: the pull requests exist, and the next submit links them again. +func (a *App) linkPart(ctx context.Context, repo git.Repo, s *stack.Stack, prs []SubmittedPR, res *SubmitResult) error { + var numbers []int + for _, e := range prs { + if e.Number > 0 { + numbers = append(numbers, e.Number) + } + } + if s.Number == 0 && len(numbers) < 2 { + return nil + } + out, err := a.d.Submit.Link(ctx, repo, stack.LinkOptions{Stack: s.Number, Trunk: s.Trunk, PRs: numbers}) + if err != nil { + res.Notices = append(res.Notices, fmt.Sprintf("could not add the pull requests to the stack on GitHub: %v", err)) + return nil + } + if !out.Streamed && out.Output != "" { + res.Output = strings.TrimSpace(res.Output + "\n" + out.Output) + } + return nil +} + +// defaultPRText is the title and body of a new pull request nobody wrote +// one for: a single commit's subject and body, or else the branch name with +// its dashes as spaces. The repository's PR template replaces the body. +func (a *App) defaultPRText(ctx context.Context, repo git.Repo, parent, branch string) PRText { + text := PRText{Title: strings.NewReplacer("-", " ", "_", " ").Replace(branch)} + if msgs, err := a.d.Git.Messages(ctx, repo, parent, branch); err == nil && len(msgs) == 1 { + subject, body, _ := strings.Cut(strings.TrimSpace(msgs[0]), "\n") + text = PRText{Title: subject, Body: strings.TrimSpace(body)} + } + if template, ok := FindPRTemplate(repo.TopLevel); ok { + text.Body = template + } + return text +} + // fixBases puts every open PR's base back on its parent branch. gh stack // treats a PR that is sitting in a merge queue like a merged one: it skips // the branch and bases the next PR on the first branch below that is neither diff --git a/pkg/app/submit_test.go b/pkg/app/submit_test.go index 3abccb4..7aa84df 100644 --- a/pkg/app/submit_test.go +++ b/pkg/app/submit_test.go @@ -25,6 +25,7 @@ type submitFake struct { forge *recordingForge graph *stack.Graph opts []stack.SubmitOptions + links []stack.LinkOptions output string // Submit's backend output streamed bool // reported as already relayed live // baseOnTrunk makes new PRs target trunk instead of their parent, the @@ -58,8 +59,14 @@ func (s *submitFake) Submit(_ context.Context, _ git.Repo, o stack.SubmitOptions return stack.SubmitResult{Output: out, Streamed: s.streamed}, nil } +func (s *submitFake) Link(_ context.Context, _ git.Repo, o stack.LinkOptions) (stack.SubmitResult, error) { + s.links = append(s.links, o) + return stack.SubmitResult{Output: "✓ linked"}, nil +} + type recordingForge struct { prs []forge.PullRequest + created []forge.CreatePR updates map[int]forge.UpdatePR lists int // merges records every MergeStack call as (number, method). @@ -95,8 +102,15 @@ func (f *recordingForge) ListPRs(context.Context, git.Repo) ([]forge.PullRequest f.lists++ return slices.Clone(f.prs), nil } -func (f *recordingForge) CreatePR(context.Context, git.Repo, forge.CreatePR) (forge.PullRequest, error) { - return forge.PullRequest{}, nil +func (f *recordingForge) CreatePR(_ context.Context, _ git.Repo, in forge.CreatePR) (forge.PullRequest, error) { + f.created = append(f.created, in) + state := forge.StateOpen + if in.Draft { + state = forge.StateDraft + } + pr := forge.PullRequest{Number: 100 + len(f.prs), Head: in.Head, Base: in.Base, State: state, Title: in.Title, URL: "u/" + in.Head} + f.prs = append(f.prs, pr) + return pr, nil } func (f *recordingForge) UpdatePR(_ context.Context, _ git.Repo, n int, in forge.UpdatePR) error { if f.updates == nil { @@ -373,3 +387,125 @@ func TestCancelledPRRefreshStopsTheCommand(t *testing.T) { t.Errorf("nothing may be submitted after Ctrl-C: submits=%d updates=%d", len(sf.opts), len(fg.updates)) } } + +// partialFixture extends submitFixture with c on top of b, pushes all three +// to a bare origin, then amends b and c so both need a force push. HEAD is +// on b. +func partialFixture(t *testing.T) (app.Deps, *submitFake, *recordingForge, git.Repo, string) { + t.Helper() + deps, sf, fg, repo, dir := submitFixture(t) + gittest.Run(t, dir, "switch", "-q", "-c", "c") + gittest.Commit(t, dir, "c.txt", "c", "feat: c") + gittest.InitRemote(t, dir) + gittest.Run(t, dir, "commit", "-q", "--amend", "-m", "feat: c again") + gittest.Run(t, dir, "switch", "-q", "b") + gittest.Run(t, dir, "commit", "-q", "--amend", "-m", "feat: b\n\nbody b") + sf.graph.Stacks[0].Branches = append(sf.graph.Stacks[0].Branches, stack.Branch{Name: "c"}) + return deps, sf, fg, repo, dir +} + +func remoteTip(t *testing.T, dir, branch string) string { + t.Helper() + out, _ := gittest.RunErr(t, dir, "ls-remote", "origin", "refs/heads/"+branch) + sha, _, _ := strings.Cut(out, "\t") + return sha +} + +func TestSubmitDownLeavesTheOnesAboveAlone(t *testing.T) { + deps, sf, fg, repo, dir := partialFixture(t) + res, err := app.New(deps).Submit(context.Background(), repo, app.SubmitOptions{Down: true, NoEdit: true}) + if err != nil { + t.Fatal(err) + } + if len(sf.opts) != 0 { + t.Errorf("gh stack submit pushes every branch, so it must not run: %+v", sf.opts) + } + if got, want := remoteTip(t, dir, "b"), gittest.Run(t, dir, "rev-parse", "b"); got != want { + t.Errorf("b on origin = %s, want %s", got, want) + } + if remoteTip(t, dir, "c") == gittest.Run(t, dir, "rev-parse", "c") { + t.Error("c is above b and must not be pushed") + } + want := []forge.CreatePR{{Head: "b", Base: "a", Title: "feat: b", Body: "## Why\n"}} + if !slices.Equal(fg.created, want) { + t.Errorf("created = %+v, want %+v", fg.created, want) + } + if len(sf.links) != 1 || sf.links[0].Stack != 0 || sf.links[0].Trunk != "main" || !slices.Equal(sf.links[0].PRs, []int{7, 101}) { + t.Errorf("links = %+v", sf.links) + } + if len(res.PullRequests) != 2 || !res.PullRequests[1].Created || res.PullRequests[1].Number != 101 || res.PullRequests[0].Created { + t.Errorf("result = %+v", res.PullRequests) + } +} + +func TestSubmitPartUsesSuppliedTextsAndTheKnownStack(t *testing.T) { + deps, sf, fg, repo, _ := partialFixture(t) + sf.graph.Stacks[0].Number = 5 + res, err := app.New(deps).Submit(context.Background(), repo, app.SubmitOptions{ + Down: true, + Draft: true, + Texts: map[string]app.PRText{"b": {Title: "Mine", Body: "why"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(fg.created) != 1 || fg.created[0].Title != "Mine" || fg.created[0].Body != "why" || !fg.created[0].Draft { + t.Errorf("created = %+v", fg.created) + } + if len(fg.updates) != 0 { + t.Errorf("the text went in at creation; nothing to update: %+v", fg.updates) + } + if !res.PullRequests[1].TextUpdated { + t.Errorf("b = %+v", res.PullRequests[1]) + } + if len(sf.links) != 1 || sf.links[0].Stack != 5 { + t.Errorf("links = %+v", sf.links) + } +} + +func TestSubmitNamedBranchWithAPRNeedsNoLink(t *testing.T) { + deps, sf, fg, repo, _ := partialFixture(t) + res, err := app.New(deps).Submit(context.Background(), repo, app.SubmitOptions{Branch: "a", NoEdit: true}) + if err != nil { + t.Fatal(err) + } + if len(sf.opts) != 0 || len(sf.links) != 0 || len(fg.created) != 0 { + t.Errorf("a has its PR and no stack exists yet: opts %+v links %+v created %+v", sf.opts, sf.links, fg.created) + } + if len(res.PullRequests) != 1 || res.PullRequests[0].Number != 7 { + t.Errorf("result = %+v", res.PullRequests) + } +} + +func TestSubmitDefaultsToTheWholeStack(t *testing.T) { + deps, sf, fg, repo, _ := partialFixture(t) + res, err := app.New(deps).Submit(context.Background(), repo, app.SubmitOptions{NoEdit: true}) + if err != nil { + t.Fatal(err) + } + if len(sf.opts) != 1 || len(sf.links) != 0 || len(fg.created) != 0 { + t.Errorf("the whole stack goes through gh stack submit: opts %+v links %+v created %+v", sf.opts, sf.links, fg.created) + } + if len(res.PullRequests) != 3 { + t.Errorf("result = %+v", res.PullRequests) + } +} + +func TestSubmitTargetErrors(t *testing.T) { + deps, _, _, repo, _ := partialFixture(t) + a := app.New(deps) + ctx := context.Background() + if _, err := a.Submit(ctx, repo, app.SubmitOptions{Branch: "nope"}); kindOf(err) != stack.KindNotInStack { + t.Errorf("unknown branch: %v", err) + } + if _, err := a.Submit(ctx, repo, app.SubmitOptions{Branch: "main"}); kindOf(err) != stack.KindNotInStack { + t.Errorf("trunk: %v", err) + } +} + +func kindOf(err error) stack.Kind { + if se, ok := errors.AsType[*stack.Error](err); ok { + return se.Kind + } + return stack.KindUnknown +} diff --git a/pkg/backend/ghstack/submit_test.go b/pkg/backend/ghstack/submit_test.go index a6fb569..11c461e 100644 --- a/pkg/backend/ghstack/submit_test.go +++ b/pkg/backend/ghstack/submit_test.go @@ -59,3 +59,25 @@ func TestSubmit(t *testing.T) { t.Errorf("exit 9: %v", err) } } + +func TestLink(t *testing.T) { + f := installed() + f.On("gh", "stack").Do(func(c exec.Cmd) (exec.Result, error) { + return exec.Result{Stderr: []byte("✓ linked\n")}, nil + }) + b := New(f, nil) + ctx := context.Background() + repo := git.Repo{TopLevel: "/r"} + + res, err := b.Link(ctx, repo, stack.LinkOptions{Trunk: "main", PRs: []int{7, 8}}) + if err != nil || res.Output != "✓ linked" || lastGh(f) != "stack link --base main 7 8" { + t.Errorf("new stack: %+v %v %q", res, err, lastGh(f)) + } + if _, err := b.Link(ctx, repo, stack.LinkOptions{Stack: 3, Trunk: "main", PRs: []int{9}}); err != nil || lastGh(f) != "stack link 3 9" { + t.Errorf("existing stack: %v %q", err, lastGh(f)) + } + calls := f.CallsTo("gh") + if calls[len(calls)-1].Mode != exec.Capture { + t.Error("link must be captured") + } +} diff --git a/pkg/backend/ghstack/submitter.go b/pkg/backend/ghstack/submitter.go index 43f6e34..0c79d99 100644 --- a/pkg/backend/ghstack/submitter.go +++ b/pkg/backend/ghstack/submitter.go @@ -3,6 +3,7 @@ package ghstack import ( "context" "errors" + "strconv" "github.com/DomBlack/git-stack/pkg/exec" "github.com/DomBlack/git-stack/pkg/git" @@ -32,6 +33,25 @@ func (b *Backend) Submit(ctx context.Context, repo git.Repo, o stack.SubmitOptio return stack.SubmitResult{Output: res.Err(), Streamed: b.out != nil}, err } +// Link runs `gh stack link` with pull request numbers. Numbers resolve to +// pull requests rather than branches, so gh stack pushes nothing. +func (b *Backend) Link(ctx context.Context, repo git.Repo, o stack.LinkOptions) (stack.SubmitResult, error) { + if err := b.Preflight(ctx); err != nil { + return stack.SubmitResult{}, err + } + args := []string{"link"} + if o.Stack > 0 { + args = append(args, strconv.Itoa(o.Stack)) + } else if o.Trunk != "" { + args = append(args, "--base", o.Trunk) + } + for _, n := range o.PRs { + args = append(args, strconv.Itoa(n)) + } + res, err := b.ghRun(ctx, repo, b.out, args...) + return stack.SubmitResult{Output: res.Err(), Streamed: b.out != nil}, err +} + // ghTTY runs `gh stack ` with the terminal attached. Only the CLI's // runner can do this; the MCP runner returns ErrTTYUnavailable, which is // mapped to an interaction error. diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 23ac6b2..aeaf6f4 100644 --- a/pkg/mcp/instructions.go +++ b/pkg/mcp/instructions.go @@ -2,7 +2,7 @@ package mcp // Instructions is sent to clients at initialisation. Codex weighs the start // of it most, so the key guidance comes first. -const Instructions = `git-stack manages stacked branches and stacked pull requests on top of GitHub's gh stack. Prefer these tools over raw git whenever the user works in a stack: stack_view shows every stack (branches, parents, PR state, needs_restack); stack_create starts a new branch on top of the current one (you write the commit message); stack_modify amends the current branch (or adds a commit) and restacks everything above it; stack_absorb folds each staged hunk into the commit of the stack that last changed those lines and rewrites that commit and everything above it (dry_run shows the plan first); use it to fix something lower down without navigating there; stack_restack rebases the stack locally without checking anything out, bottom branch onto the local trunk included; on a conflict it leaves one git rebase in progress for the files to be resolved; stack_continue finishes an interrupted restack once the conflicts are resolved and git added; stack_abort gives it up and puts every moved branch back; stack_navigate moves up/down/top/bottom or checks out a branch; stack_submit pushes and creates or updates PRs (you write titles and bodies; ready for review unless draft is true); stack_sync fetches, fast forwards trunk, deletes branches whose pull requests merged or closed, fast forwards branches that moved on the remote and restacks every stack without checking anything out; it never pushes. stack_merge merges the stack's pull requests up to a branch into trunk in one all or nothing operation (drafts must be published first) and then syncs; it refuses while any of those pull requests has a check that failed (code checks_failing) or is still running (code checks_pending), listing them in the error's checks field. Fix the failures and push with stack_submit, or wait for running checks and call it again; only pass force: true when the user has said to merge anyway. +const Instructions = `git-stack manages stacked branches and stacked pull requests on top of GitHub's gh stack. Prefer these tools over raw git whenever the user works in a stack: stack_view shows every stack (branches, parents, PR state, needs_restack); stack_create starts a new branch on top of the current one (you write the commit message); stack_modify amends the current branch (or adds a commit) and restacks everything above it; stack_absorb folds each staged hunk into the commit of the stack that last changed those lines and rewrites that commit and everything above it (dry_run shows the plan first); use it to fix something lower down without navigating there; stack_restack rebases the stack locally without checking anything out, bottom branch onto the local trunk included; on a conflict it leaves one git rebase in progress for the files to be resolved; stack_continue finishes an interrupted restack once the conflicts are resolved and git added; stack_abort gives it up and puts every moved branch back; stack_navigate moves up/down/top/bottom or checks out a branch; stack_submit pushes the stack (or with down only the current branch and the ones below it) and creates or updates PRs (you write titles and bodies; ready for review unless draft is true); stack_sync fetches, fast forwards trunk, deletes branches whose pull requests merged or closed, fast forwards branches that moved on the remote and restacks every stack without checking anything out; it never pushes. stack_merge merges the stack's pull requests up to a branch into trunk in one all or nothing operation (drafts must be published first) and then syncs; it refuses while any of those pull requests has a check that failed (code checks_failing) or is still running (code checks_pending), listing them in the error's checks field. Fix the failures and push with stack_submit, or wait for running checks and call it again; only pass force: true when the user has said to merge anyway. Never run git rebase or git commit --amend by hand inside a stack: descendants would not be restacked. Use stack_modify / stack_restack instead. diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 7d33f0a..581c5ac 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -344,6 +344,8 @@ type submitInput struct { PullRequests map[string]app.PRText `json:"pull_requests,omitempty" jsonschema:"title and body per branch name; write these yourself for every branch that has no PR yet (see stack_view)"` UseAI bool `json:"use_ai,omitempty" jsonschema:"let git-stack's own AI draft missing titles and bodies (opt-in)"` Force bool `json:"force,omitempty" jsonschema:"push even when a branch has commits on the remote that aren't in the local branch, overwriting them; only with the user's say so"` + Down bool `json:"down,omitempty" jsonschema:"only submit the current branch and the ones below it; the ones above are left alone"` + Branch string `json:"branch,omitempty" jsonschema:"only submit this branch and the ones below it, whichever branch is current"` } func (s *Server) submit(ctx context.Context, req *mcp.CallToolRequest, in submitInput) (*mcp.CallToolResult, app.SubmitResult, error) { @@ -353,6 +355,7 @@ func (s *Server) submit(ctx context.Context, req *mcp.CallToolRequest, in submit } res, err := a.Submit(ctx, repo, app.SubmitOptions{ Publish: in.Publish, Draft: in.Draft, NoEdit: true, DryRun: in.DryRun, UseAI: in.UseAI, Texts: in.PullRequests, Force: in.Force, + Down: in.Down, Branch: in.Branch, }) if err != nil { return nil, app.SubmitResult{}, wrapErr(err) @@ -506,7 +509,7 @@ func (s *Server) registerTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_submit", Title: "Submit the stack", - Description: "Push every branch of the current stack and create or update chained pull requests on GitHub. New PRs are ready for review unless draft is true (or git config stack.submit.default says draft). Supply pull_requests {branch: {title, body}} for branches without a PR; use dry_run to see the plan first. Refuses with invalid_args when a branch has commits on the remote that aren't local (someone else pushed); pull them in, or pass force only if the user agrees to overwrite them.", + Description: "Push every branch of the current stack and create or update chained pull requests on GitHub; with down (or branch) only the current (or named) branch and the ones below it. New PRs are ready for review unless draft is true (or git config stack.submit.default says draft). Supply pull_requests {branch: {title, body}} for branches without a PR; use dry_run to see the plan first. Refuses with invalid_args when a branch has commits on the remote that aren't local (someone else pushed); pull them in, or pass force only if the user agrees to overwrite them.", Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(true)}, }, s.submit) mcp.AddTool(s.mcp, &mcp.Tool{ diff --git a/pkg/stack/ports.go b/pkg/stack/ports.go index cbe536e..2c5b6ef 100644 --- a/pkg/stack/ports.go +++ b/pkg/stack/ports.go @@ -70,7 +70,21 @@ type SubmitResult struct { Streamed bool } +// LinkOptions controls Submitter.Link. +type LinkOptions struct { + // Stack is the remote stack to add the pull requests to; 0 starts a new + // one, which needs at least two pull requests. + Stack int + // Trunk is the base of the bottom pull request of a new stack. + Trunk string + // PRs are pull request numbers, bottom to top. + PRs []int +} + // Submitter pushes the current stack and creates or updates its PRs. type Submitter interface { Submit(ctx context.Context, repo git.Repo, o SubmitOptions) (SubmitResult, error) + // Link joins pull requests that already exist into a stack on the + // remote, without pushing any branch. + Link(ctx context.Context, repo git.Repo, o LinkOptions) (SubmitResult, error) } diff --git a/pkg/stack/stacktest/backend.go b/pkg/stack/stacktest/backend.go index bb56700..58fd862 100644 --- a/pkg/stack/stacktest/backend.go +++ b/pkg/stack/stacktest/backend.go @@ -21,6 +21,8 @@ type Backend struct { // Submits records submit options; SubmitFn, when set, runs on submit. Submits []stack.SubmitOptions SubmitFn func(o stack.SubmitOptions) error + // Links records link options. + Links []stack.LinkOptions } // New returns a Backend over g with the given initial graph. @@ -113,6 +115,13 @@ func (b *Backend) Submit(_ context.Context, _ git.Repo, o stack.SubmitOptions) ( return stack.SubmitResult{Output: "submitted"}, nil } +func (b *Backend) Link(_ context.Context, _ git.Repo, o stack.LinkOptions) (stack.SubmitResult, error) { + b.mu.Lock() + b.Links = append(b.Links, o) + b.mu.Unlock() + return stack.SubmitResult{Output: "linked"}, nil +} + // Compile-time checks. var ( _ stack.Metadata = (*Backend)(nil)