diff --git a/README.md b/README.md index d2a352e..9c81c1c 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,11 @@ merging the PRs on GitHub. ## What it doesn't do -gh stack can only add branches at the top of a stack, so `git create` can't start one in the -middle either; create it on top and `git reorder` it into place. Reordering a stack, moving -branches onto another stack, folding a branch into the one below it, dropping one from a stack and -adopting branches you made by hand are `git stack reorder`, `git stack move`, `git stack fold`, -`git stack pop` and `git stack init`, so nothing needs gh stack directly any more. +gh stack can only add branches at the top of a stack, so `git create --insert` does the middle +of a stack itself, the same way a reorder does. Reordering a stack, moving branches onto another +stack, folding a branch into the one below it, dropping one from a stack and adopting branches you +made by hand are `git stack reorder`, `git stack move`, `git stack fold`, `git stack pop` and +`git stack init`, so nothing needs gh stack directly any more. ## Install @@ -248,7 +248,16 @@ Creates a new branch on top of the current one and commits whatever is staged. I nothing is staged you get an empty branch. It works from main (starting a new stack) or from the top of a stack. If you're further down, run -`git top` first. +`git top` first, or pass `--insert` to put the new branch right above the one you're on: the +branches that were above it are restacked onto it, a conflict stops like a restack (`git continue` +or `git abort`, which puts those branches back and keeps the new branch with its commit), and if +the stack already has PRs the next `git ss` creates it on GitHub again in the new order. + +```sh +git down 2 +git create -i -a -m "Split out the config parsing" + # new branch right above this one; the rest are restacked onto it +``` Flags: - `-a` / `--all` stage all changes, including untracked files, before committing @@ -259,6 +268,7 @@ Flags: (`git config stack.ai.auto true` makes this the default) - `--no-ai` don't use AI, even if `stack.ai.auto` is set - `--no-verify` skip git hooks when committing +- `-i` / `--insert` put the branch right above the current one, even in the middle of a stack ### `git stack modify` diff --git a/cmd/create.go b/cmd/create.go index d36a9a8..f505cd3 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -13,6 +13,7 @@ func newCreateCmd(c *cli) *cobra.Command { useAI bool noAI bool noVerify bool + insert bool ) cmd := &cobra.Command{ Use: "create [name]", @@ -22,9 +23,13 @@ func newCreateCmd(c *cli) *cobra.Command { It needs a name, -m or --ai; without a name the branch name comes from the commit message (or from --ai). If nothing is staged you get an empty branch. -It works from trunk (starting a new stack) or from the top of a stack, since gh -stack can only add branches at the top. Run git stack top first if you're -further down.`, +It works from trunk (starting a new stack) or from the top of a stack. Further +down, --insert puts the new branch right above the current one and restacks +the branches that were above onto it. A conflict there stops like a restack: +fix the files, git add them and run "git stack continue", or "git stack abort" +to put those branches back (the new branch keeps its commit). If the stack +already has pull requests, the next submit creates the stack on GitHub again +in the new order. Without --insert, run git stack top first.`, Example: ` # stage everything; the branch name comes from the message git stack create -a -m "Add retries to the billing webhook" @@ -32,7 +37,10 @@ further down.`, git create fix-login -m "Fix the login timeout" -m "Sessions expired early." # let Claude name the branch and write the message from the diff - git create -a --ai`, + git create -a --ai + + # add a branch in the middle of the stack, right above this one + git create -i -a -m "Split out the config parsing"`, Args: cobra.MaximumNArgs(1), ValidArgsFunction: completeNothing, RunE: func(cmd *cobra.Command, args []string) error { @@ -51,20 +59,31 @@ further down.`, Staging: staging, UseAI: ai, NoVerify: noVerify, + Insert: insert, } if len(args) == 1 { o.Name = args[0] } res, err := a.Create(ctx, repo, o) - if err != nil { + if err != nil && res.Branch == "" { return err } rep := c.report() + where := "on" + if res.Inserted { + where = "above" + } switch { case res.Commit != nil: - rep.Success("Created %s on %s %s %s", rep.Branch(res.Branch), rep.Branch(res.Parent), rep.SHA(res.Commit.Short()), res.Commit.Subject) + rep.Success("Created %s %s %s %s %s", rep.Branch(res.Branch), where, rep.Branch(res.Parent), rep.SHA(res.Commit.Short()), res.Commit.Subject) default: - rep.Success("Created empty branch %s on %s (nothing was staged)", rep.Branch(res.Branch), rep.Branch(res.Parent)) + rep.Success("Created empty branch %s %s %s (nothing was staged)", rep.Branch(res.Branch), where, rep.Branch(res.Parent)) + } + if err != nil { + return err + } + if n := len(res.Restacked); n > 0 { + rep.Success("Restacked %d %s above %s", n, branchNoun(n), rep.Branch(res.Branch)) } return nil }, @@ -74,6 +93,7 @@ further down.`, st.add(cmd) cmd.Flags().BoolVar(&useAI, "ai", false, "have Claude Code name the branch and write the message") cmd.Flags().BoolVar(&noVerify, "no-verify", false, "skip git hooks when committing") + cmd.Flags().BoolVarP(&insert, "insert", "i", false, "put the branch right above the current one, even mid-stack, and restack the ones above onto it") cmd.Flags().BoolVar(&noAI, "no-ai", false, "never use AI; takes precedence over --ai and stack.ai.auto") return cmd } diff --git a/cmd/reshape_test.go b/cmd/reshape_test.go index 29e553a..daec326 100644 --- a/cmd/reshape_test.go +++ b/cmd/reshape_test.go @@ -88,3 +88,24 @@ func TestInitCommand(t *testing.T) { t.Errorf("tracked branch: %v", err) } } + +func TestCreateInsertCommand(t *testing.T) { + dir, run := stackOf3(t) + gittest.Run(t, dir, "switch", "-q", "a") + gittest.WriteFile(t, dir, "n.txt", "n") + out, errOut, err := run("create", "-i", "-a", "-m", "feat: n", "n") + if err != nil || !strings.Contains(out, "ok: Created n above a ") || !strings.Contains(out, "Restacked 2 branches above n") { + t.Errorf("create --insert: %q %q %v", out, errOut, err) + } + if _, err := gittest.RunErr(t, dir, "cat-file", "-e", "c:n.txt"); err != nil { + t.Error("c should sit on n") + } + out, _, err = run("log") + if err != nil || strings.Index(out, "○ b\n") > strings.Index(out, "● n\n") || strings.Index(out, "● n\n") > strings.Index(out, "○ a\n") { + t.Errorf("log after insert: %q %v", out, err) + } + gittest.Run(t, dir, "switch", "-q", "main") + if _, _, err = run("create", "--insert", "x"); err == nil || !strings.Contains(err.Error(), "is a trunk") { + t.Errorf("--insert on trunk: %v", err) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index d2c569f..e6ab854 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -192,8 +192,8 @@ rather than a confusing git error. **`create` from trunk runs `gh stack init`, from the top runs `gh stack add`, and we always commit natively.** This sidesteps both `add` quirks above. From the middle of a -stack you get a `not_at_top` error with next steps; from an untracked branch you get told -the branch isn't in a stack. The branch is created, checked out and registered before the +stack you get a `not_at_top` error with next steps (unless you pass `--insert`, see below); +from an untracked branch you get told the branch isn't in a stack. The branch is created, checked out and registered before the commit, so when the commit fails (a signing key that isn't unlocked, a hook saying no) create undoes all of that, forgetting the branch through `Update`, switching back and deleting it, and the staged changes are still staged; the error says to run it again. If the undo itself @@ -435,6 +435,18 @@ metadata (the snapshot lists it), since it was never deleted and HEAD never move goes on the close queue next to the dissolves, so the next submit closes it once gh stack has moved the PR above it onto the new parent. +**`create --insert` is a reshape, then a commit, then a restack like modify's.** gh stack can't add +a branch below the top, so we create and check out the branch with git, and a reshape with no +restack segments puts it in the list right above the current branch, fixes the base of the +branch that was above, and clears and queues the stack's GitHub id like a reorder. Then we +commit, and if that fails it's undone like any create (the snapshot goes back first). Only then +are the branches above restacked onto the new commit, as a plain restack rather than part of the +reshape, so a conflict's abort puts those branches back but leaves the new branch in the stack +with its commit, the same as aborting the restack after a `modify`. Putting the order back on +abort would have left the new branch untracked, holding a commit with nowhere to go, and +undoing the create from inside abort would mean moving that commit back into the index after +the fact. On the top branch `--insert` is a plain create. + **GitHub's stack is dissolved and made again, not edited.** The stacks API can create a stack from a list of PRs and add PRs to the top of one, and that's all. Left alone, `gh stack submit` after a reorder pushes every branch and fixes each PR's base, then sees the stack on GitHub is diff --git a/docs/mcp.md b/docs/mcp.md index ad5421b..d8fd280 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no | Tool | Purpose | Key inputs | Annotations | |---|---|---|---| | `stack_view` | Tree of every stack: branches, parents, PR number/state/URL/title, each branch's own `commits`, `needs_restack`, `needs_push`, current branch, untracked branches | `include_untracked`, `fresh` (default true) | read-only, open-world | -| `stack_create` | New branch on top of the current one (from trunk: new stack), commit staged changes | `message` (required), `branch`, `staging` = `all`/`update`/`none`, `use_ai` | non-destructive | +| `stack_create` | New branch on top of the current one (from trunk: new stack), commit staged changes; with `insert`, right above the current branch even mid-stack, restacking the branches above onto it (the next `stack_submit` regroups the stack on GitHub; a conflict is finished with `stack_continue`, and `stack_abort` puts those branches back but keeps the new branch) | `message` (required), `branch`, `staging` = `all`/`update`/`none`, `use_ai`, `insert` | destructive | | `stack_modify` | Amend the current branch (or `mode: commit`) and restack everything above | `mode`, `staging`, `message`, `continue`, `abort` | destructive | | `stack_absorb` | Fold each staged hunk into the commit of the current stack that last changed those lines, rewrite it and everything above, then restack what sits above the current branch; the result's `plan` lists every hunk's destination and what stays staged | `staging` = `all`/`update`/`none`, `dry_run` | destructive | | `stack_reorder` | Change the order of the current stack's branches and restack them, checking nothing out; the next `stack_submit` dissolves the stale stack on GitHub and creates it again in the new order; a branch checked out with changes in another worktree refuses the call with nothing changed | `order` (required: every branch of the stack once, bottom to top) | destructive | diff --git a/docs/style.md b/docs/style.md index 9fce694..e602898 100644 --- a/docs/style.md +++ b/docs/style.md @@ -31,6 +31,7 @@ success, nothing says more than it needs to, and the whole thing goes through ❯ git create ✖ feat-c is not the top of its stack (top is feat-d) ↳ run git stack top and create the branch there + ↳ or pass --insert to put it right above feat-c ↳ gh stack can only add branches at the top ``` diff --git a/pkg/app/create.go b/pkg/app/create.go index 5bf5ca0..4d15f96 100644 --- a/pkg/app/create.go +++ b/pkg/app/create.go @@ -24,6 +24,9 @@ type CreateOptions struct { // UseAI drafts the branch name (and the message when none is given). UseAI bool NoVerify bool + // Insert puts the branch directly above the current one, in the middle + // of its stack, and restacks the branches that were above onto it. + Insert bool } // CreateResult reports what happened. @@ -33,6 +36,10 @@ type CreateResult struct { Commit *CommitInfo `json:"commit,omitempty"` // NewStack is true when the branch started a new stack on trunk. NewStack bool `json:"newStack"` + // Inserted is true when the branch went in below other branches of the + // stack; Restacked lists the ones moved onto it, bottom to top. + Inserted bool `json:"inserted,omitempty"` + Restacked []string `json:"restacked,omitempty"` } // recentSubjectsCount is how many trunk subjects feed the AI prompt. @@ -43,7 +50,9 @@ const recentSubjectsCount = 10 const undoTimeout = 30 * time.Second // Create stages, creates a branch on top of the current one, commits and -// registers the branch with the backend. +// registers the branch with the backend. With Insert on a branch that is +// not the top of its stack, the branch goes in right above the current one +// and the branches above are restacked onto it afterwards. func (a *App) Create(ctx context.Context, repo git.Repo, o CreateOptions) (CreateResult, error) { if a.d.Tracker == nil { return CreateResult{}, stack.New(stack.KindUnsupported, "no stack backend configured") @@ -61,15 +70,30 @@ func (a *App) Create(ctx context.Context, repo git.Repo, o CreateOptions) (Creat } var trunk string - newStack := false + newStack, insert := false, false + var snap *reshapeSnapshot switch s, i, ok := graph.StackOf(current); { case graph.IsTrunk(current) || a.isDefaultBranch(ctx, repo, graph, current): + if o.Insert { + return CreateResult{}, stack.Newf(stack.KindInvalidArgs, "%s is a trunk; --insert puts a branch in the middle of a stack", current). + WithSteps("drop --insert to start a new stack here") + } trunk, newStack = current, true case ok && i == len(s.Branches)-1: trunk = s.Trunk + case ok && o.Insert: + if err := a.noRebaseActive(ctx, repo); err != nil { + return CreateResult{}, err + } + if err := noMergedBranches(s); err != nil { + return CreateResult{}, err + } + trunk, insert = s.Trunk, true + snap = snapshotOf(graph, []string{s.Bottom()}) case ok: return CreateResult{}, stack.Newf(stack.KindNotAtTop, "%s is not the top of its stack (top is %s)", current, s.Top()). WithSteps("run `git stack top` and create the branch there", + "or pass --insert to put it right above "+current, "gh stack can only add branches at the top of a stack") default: return CreateResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", current). @@ -88,34 +112,96 @@ func (a *App) Create(ctx context.Context, repo git.Repo, o CreateOptions) (Creat } err = a.progress(ctx, PhaseCreate, fmt.Sprintf("Creating branch %s", name), func(ctx context.Context) error { - if newStack { + switch { + case newStack: return a.d.Tracker.InitStack(ctx, repo, trunk, []string{name}) + case insert: + return a.insertBranch(ctx, repo, name, current) } return a.d.Tracker.AddTop(ctx, repo, name) }) if err != nil { return CreateResult{}, err } - res := CreateResult{Branch: name, Parent: current, NewStack: newStack} + res := CreateResult{Branch: name, Parent: current, NewStack: newStack, Inserted: insert} if !staged { + // The new branch is the current one's tip, so the branches above + // are already on it. return res, nil } sha, err := a.d.Git.Commit(ctx, repo, git.CommitOptions{Message: message, NoVerify: o.NoVerify}) if err != nil { - return CreateResult{}, a.undoCreate(ctx, repo, name, current, commitError(err, "pass -m ")) + return CreateResult{}, a.undoCreate(ctx, repo, name, current, snap, commitError(err, "pass -m ")) } info := a.commitInfo(ctx, repo, sha) res.Commit = &info - return res, nil + if !insert { + return res, nil + } + // The branches above move onto the new commit like after a modify; a + // conflict stops there, and abort puts them back, leaving the new + // branch and its commit in the stack. + n := len(upstack(graph, current)) + headline := fmt.Sprintf("Restacking %d %s above %s", n, pluralise(n, "branch", "branches"), name) + rr, err := a.restack(ctx, repo, RestackOptions{Scope: stack.ScopeUpstack}, restackCommand, headline) + res.Restacked = movedNames(rr) + return res, err +} + +// insertBranch creates name on parent, checks it out and records it in +// parent's stack right above it, which makes the stack's pull requests +// stale on GitHub: its remote id is cleared and queued for dissolving, as +// a reorder does. Nothing else moves yet; name is parent's tip. +func (a *App) insertBranch(ctx context.Context, repo git.Repo, name, parent string) error { + if err := a.d.Git.CreateBranch(ctx, repo, name, parent); err != nil { + return err + } + if err := a.d.Git.Switch(ctx, repo, name); err != nil { + if derr := a.d.Git.DeleteBranch(ctx, repo, name); derr != nil { + a.d.Log.Debug("undo insert", "branch", name, "err", derr) + } + return err + } + graph, err := a.d.Meta.Load(ctx, repo) + if err != nil { + return err + } + s, _, ok := graph.StackOf(parent) + if !ok { + return stack.Newf(stack.KindNotInStack, "%s is not in a stack", parent) + } + bottom := s.Bottom() + e := reshapeEdit{ + touched: []string{bottom}, + relink: []string{bottom}, + edit: func(g *stack.Graph) error { + s, i, ok := g.StackOf(parent) + if !ok { + return stack.Newf(stack.KindInvalidArgs, "%s is no longer in a stack", parent) + } + s.Branches = slices.Insert(s.Branches, i+1, stack.Branch{Name: name}) + return nil + }, + } + if _, err := a.reshape(ctx, repo, createCommand, "", e); err != nil { + if uerr := errors.Join(a.d.Git.Switch(ctx, repo, parent), a.d.Git.DeleteBranch(ctx, repo, name)); uerr != nil { + a.d.Log.Debug("undo insert", "branch", name, "err", uerr) + } + return err + } + return nil } +const createCommand = "git stack create" + // undoCreate makes a failed create all or nothing: the branch was already // created, checked out and registered before the commit failed (a locked // signing key, a hook saying no), so forget it, go back to the parent and -// delete it. The staged changes survive, both branches point at the same -// commit. The error says to run create again, or, when undoing failed too, -// what was left behind and how to commit on it instead. -func (a *App) undoCreate(ctx context.Context, repo git.Repo, name, parent string, cause error) error { +// delete it. snap, when the branch was inserted, is the stack's shape from +// before, put back first. The staged changes survive, both branches point +// at the same commit. The error says to run create again, or, when undoing +// failed too, what was left behind and how to commit on it instead. +func (a *App) undoCreate(ctx context.Context, repo git.Repo, name, parent string, snap *reshapeSnapshot, cause error) error { err := asStackError(cause) // The commit may have failed because ctx was cancelled; the undo still // has to run, so give it its own bounded context. @@ -126,10 +212,19 @@ func (a *App) undoCreate(ctx context.Context, repo git.Repo, name, parent string // and must stay: deleting it would orphan the work. if tip, ok, terr := a.d.Git.Tip(ctx, repo, name); terr == nil && ok { if parentTip, _, perr := a.d.Git.Tip(ctx, repo, parent); perr == nil && tip != parentTip { - return err.WithSteps(fmt.Sprintf("%s has a commit after all, so it was kept and is tracked; check `git stack log`", name)) + err = err.WithSteps(fmt.Sprintf("%s has a commit after all, so it was kept and is tracked; check `git stack log`", name)) + if snap != nil { + err = err.WithSteps("git stack restack moves the branches above onto it") + } + return err } } undo := func() error { + if snap != nil { + if err := a.restoreReshape(ctx, repo, snap); err != nil { + return fmt.Errorf("put the stack back: %w", err) + } + } if err := a.d.Meta.Update(ctx, repo, func(g *stack.Graph) error { for i := range g.Stacks { g.Stacks[i].Branches = slices.DeleteFunc(g.Stacks[i].Branches, func(b stack.Branch) bool { return b.Name == name }) diff --git a/pkg/app/create_insert_test.go b/pkg/app/create_insert_test.go new file mode 100644 index 0000000..d23cb9b --- /dev/null +++ b/pkg/app/create_insert_test.go @@ -0,0 +1,168 @@ +package app_test + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// insertFixture is main -> a -> b -> c with a remote stack id, HEAD on a. +func insertFixture(t *testing.T) *restackFixture { + t.Helper() + f := newRestackFixture(t) + f.fb.graph.Stacks[0].ID, f.fb.graph.Stacks[0].Number = "S1", 4 + gittest.Run(t, f.dir, "switch", "-q", "a") + return f +} + +func TestCreateInsertPutsTheBranchAboveTheCurrentOne(t *testing.T) { + f := insertFixture(t) + ctx := context.Background() + aTip := f.rev(t, "a") + gittest.WriteFile(t, f.dir, "n.txt", "n") + gittest.Run(t, f.dir, "add", "n.txt") + + res, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Message: []string{"Add n"}, Insert: true}) + if err != nil { + t.Fatal(err) + } + if res.Branch != "n" || res.Parent != "a" || res.Commit == nil || !res.Inserted || !slices.Equal(res.Restacked, []string{"b", "c"}) { + t.Errorf("result = %+v", res) + } + if !slices.Equal(f.names(), []string{"a", "n", "b", "c"}) { + t.Errorf("order = %v", f.names()) + } + if cur := gittest.Run(t, f.dir, "branch", "--show-current"); cur != "n" { + t.Errorf("on %s, want n", cur) + } + if !f.has(t, "b", "n.txt") || !f.has(t, "c", "n.txt") || !f.has(t, "c", "b.txt") { + t.Error("b and c should sit on n now") + } + if f.base("n") != aTip || f.base("b") != f.rev(t, "n") { + t.Errorf("bases n=%s b=%s", f.base("n"), f.base("b")) + } + if s := f.fb.graph.Stacks[0]; s.ID != "" || s.Number != 0 { + t.Errorf("remote id = %q #%d, want cleared", s.ID, s.Number) + } + if q := pendingQueue(t, f); !strings.Contains(q, `"S1"`) { + t.Errorf("pending = %s, want S1 queued to dissolve", q) + } +} + +func TestCreateInsertWithNothingStaged(t *testing.T) { + f := insertFixture(t) + res, err := f.app.Create(context.Background(), f.repo, app.CreateOptions{Name: "n", Insert: true}) + if err != nil { + t.Fatal(err) + } + if res.Commit != nil || !res.Inserted || len(res.Restacked) != 0 { + t.Errorf("result = %+v", res) + } + if !slices.Equal(f.names(), []string{"a", "n", "b", "c"}) { + t.Errorf("order = %v", f.names()) + } + if f.rev(t, "n") != f.rev(t, "a") { + t.Error("n should be an empty branch on a") + } +} + +func TestCreateInsertOnTopIsAPlainCreate(t *testing.T) { + f := insertFixture(t) + gittest.Run(t, f.dir, "switch", "-q", "c") + res, err := f.app.Create(context.Background(), f.repo, app.CreateOptions{Name: "n", Insert: true}) + if err != nil { + t.Fatal(err) + } + if res.Inserted || res.Parent != "c" { + t.Errorf("result = %+v", res) + } + if !slices.Equal(f.names(), []string{"a", "b", "c", "n"}) { + t.Errorf("order = %v", f.names()) + } + if f.fb.graph.Stacks[0].ID != "S1" || pendingQueue(t, f) != "" { + t.Error("adding on top keeps the GitHub stack") + } +} + +func TestCreateInsertRefusals(t *testing.T) { + f := insertFixture(t) + ctx := context.Background() + gittest.Run(t, f.dir, "switch", "-q", "main") + if _, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Insert: true}); !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) { + t.Errorf("on trunk: %v", err) + } + gittest.Run(t, f.dir, "switch", "-q", "a") + f.fb.graph.Stacks[0].Branches[0].PR = &stack.PRRef{Number: 1, Merged: true} + if _, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Insert: true}); !errors.Is(err, &stack.Error{Kind: stack.KindInvalidArgs}) || !strings.Contains(err.Error(), "merged") { + t.Errorf("merged branch: %v", err) + } + if exists, _ := f.fb.git.BranchExists(ctx, f.repo, "n"); exists { + t.Error("a refused create must not leave a branch") + } +} + +func TestCreateInsertUndoesEverythingWhenTheCommitFails(t *testing.T) { + f := insertFixture(t) + ctx := context.Background() + rejectCommits(t, f.dir) + gittest.WriteFile(t, f.dir, "n.txt", "n") + gittest.Run(t, f.dir, "add", "n.txt") + + _, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Message: []string{"Add n"}, Insert: true}) + if err == nil { + t.Fatal("expected the commit to fail") + } + if !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Errorf("order = %v, want it put back", f.names()) + } + if s := f.fb.graph.Stacks[0]; s.ID != "S1" || s.Number != 4 || pendingQueue(t, f) != "" { + t.Errorf("remote id = %q #%d, pending %q; want it all put back", s.ID, s.Number, pendingQueue(t, f)) + } + if cur := gittest.Run(t, f.dir, "branch", "--show-current"); cur != "a" { + t.Errorf("on %s, want back on a", cur) + } + if exists, _ := f.fb.git.BranchExists(ctx, f.repo, "n"); exists { + t.Error("n should have been deleted") + } + if out := gittest.Run(t, f.dir, "diff", "--cached", "--name-only"); out != "n.txt" { + t.Errorf("staged = %q, want n.txt still staged", out) + } +} + +// A conflict stops like a restack; abort puts the branches above back and +// leaves the new branch in the stack with its commit. +func TestCreateInsertConflictThenAbort(t *testing.T) { + f := insertFixture(t) + ctx := context.Background() + bTip, cTip := f.rev(t, "b"), f.rev(t, "c") + gittest.WriteFile(t, f.dir, "b.txt", "not b") + gittest.Run(t, f.dir, "add", "b.txt") + + res, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Message: []string{"Add n"}, Insert: true}) + if !errors.Is(err, &stack.Error{Kind: stack.KindConflict}) { + t.Fatalf("err = %v, want a conflict", err) + } + if res.Branch != "n" || res.Commit == nil || !res.Inserted { + t.Errorf("result = %+v, want the create reported", res) + } + nTip := f.rev(t, "n") + + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{Abort: true}); err != nil { + t.Fatal(err) + } + if !slices.Equal(f.names(), []string{"a", "n", "b", "c"}) { + t.Errorf("order = %v, want n kept in place", f.names()) + } + if f.rev(t, "b") != bTip || f.rev(t, "c") != cTip || f.rev(t, "n") != nTip { + t.Error("b and c should be back where they were, n untouched") + } + if cur := gittest.Run(t, f.dir, "branch", "--show-current"); cur != "n" { + t.Errorf("on %s, want n", cur) + } +} diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index a8e1b43..075c344 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -18,6 +18,7 @@ that touches a stack: |---|---| | See the stacks, PR state, what needs a restack | `stack_view` | | Start a branch on top of the current one (from trunk: a new stack) | `stack_create`; you write `message` | +| Add a branch in the middle of a stack, right above the current one | `stack_create` with `insert: true` (restacks the branches above onto it) | | Change the current branch's commit and rebase everything above | `stack_modify` | | Fold staged fixes into whichever commits of the stack last touched those lines | `stack_absorb` (`dry_run: true` to see the plan) | | Rebase the stack after edits lower down | `stack_restack` | @@ -37,8 +38,8 @@ the one calling. - Never `git rebase`, `git commit --amend` or `git push --force` by hand on a stacked branch: the branches above would be left behind. Use `stack_modify` / `stack_restack`. -- New work goes on a **new branch on top** (`stack_create`); gh stack cannot insert a branch - in the middle. To change something lower down, `stack_navigate` there, edit, then +- New work goes on a **new branch on top** (`stack_create`), or with `insert: true` right above + the current branch when it belongs lower down. To change something lower down, `stack_navigate` there, edit, then `stack_modify` (it restacks the rest). - On `code: "conflict"`: resolve the listed files, `git add` them, then call the same tool again with `continue: true` (or `abort: true`); after `stack_absorb`, `stack_reorder` or diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index 425005f..ed6ae6f 100644 --- a/pkg/mcp/instructions.go +++ b/pkg/mcp/instructions.go @@ -2,10 +2,10 @@ 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_reorder changes the order of the current stack's branches (order, bottom to top) and restacks them; stack_move puts a branch and what's above it on top of another stack or on a trunk as a new stack (only: true leaves the branches above behind); stack_fold merges the current branch into the branch below it, one branch and one pull request (keep: true keeps the current branch's name and pull request instead of the parent's), and the next stack_submit closes the pull request of the branch that went; stack_init tracks branches you already have as a new stack, without rebasing; stack_pop deletes the current branch and takes it out of its stack, leaving its changes staged on its parent, and restacks the branches above (its pull request is closed by the next stack_submit). stack_reorder and stack_move leave the stack's pull requests to be regrouped on GitHub: the next stack_submit dissolves every stale GitHub stack and creates the ones it did not submit again, its notices naming each (a stack with fewer than two pull requests has nothing to group; a branch without an open pull request, or a failed create, gets a notice saying to submit from that stack); 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. +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), or with insert: true right above the current branch in the middle of its stack, restacking the branches above onto it; 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_reorder changes the order of the current stack's branches (order, bottom to top) and restacks them; stack_move puts a branch and what's above it on top of another stack or on a trunk as a new stack (only: true leaves the branches above behind); stack_fold merges the current branch into the branch below it, one branch and one pull request (keep: true keeps the current branch's name and pull request instead of the parent's), and the next stack_submit closes the pull request of the branch that went; stack_init tracks branches you already have as a new stack, without rebasing; stack_pop deletes the current branch and takes it out of its stack, leaving its changes staged on its parent, and restacks the branches above (its pull request is closed by the next stack_submit). stack_reorder, stack_move and stack_create with insert leave the stack's pull requests to be regrouped on GitHub: the next stack_submit dissolves every stale GitHub stack and creates the ones it did not submit again, its notices naming each (a stack with fewer than two pull requests has nothing to group; a branch without an open pull request, or a failed create, gets a notice saying to submit from that stack); 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. -Errors are JSON objects {code, message, next_steps, files?, branch?, checks?}. On code "conflict": resolve the listed files, run git add on them, then call stack_continue (or stack_abort to give up). Code "not_at_top": gh stack can only add branches at the top of a stack; call stack_navigate {direction: "top"} first. Code "interaction_required": the operation would need a terminal; pass the required text (message, pull_requests) instead. Code "signing_failed": git could not sign the commit (usually an SSH signing key not loaded into ssh-agent; the server cannot answer a passphrase prompt); nothing was committed, so tell the user to run the ssh-add in next_steps (or turn signing off for the repository) and call the tool again; from a restack the rebase is left in progress, so call stack_continue once signing works, or stack_abort to put the branches back. Code "partial" from stack_sync: everything else was done, but the branches in the result's notUpdated could not be updated (reason locked: another git process held the lock file in the entry's lock field, of kind lockKind: "index" for that checkout's index or "ref" for the branch's own ref, which can apply to a branch with no checkout; use those fields rather than assuming an index lock; refused: git would not move the checkout for another reason, such as a merge in progress there; entries with reason dirty or untracked list the files in the way and don't fail the sync); follow next_steps and call stack_sync again. +Errors are JSON objects {code, message, next_steps, files?, branch?, checks?}. On code "conflict": resolve the listed files, run git add on them, then call stack_continue (or stack_abort to give up). Code "not_at_top": gh stack can only add branches at the top of a stack; call stack_navigate {direction: "top"} first, or pass insert: true to stack_create to put the branch right above the current one. Code "interaction_required": the operation would need a terminal; pass the required text (message, pull_requests) instead. Code "signing_failed": git could not sign the commit (usually an SSH signing key not loaded into ssh-agent; the server cannot answer a passphrase prompt); nothing was committed, so tell the user to run the ssh-add in next_steps (or turn signing off for the repository) and call the tool again; from a restack the rebase is left in progress, so call stack_continue once signing works, or stack_abort to put the branches back. Code "partial" from stack_sync: everything else was done, but the branches in the result's notUpdated could not be updated (reason locked: another git process held the lock file in the entry's lock field, of kind lockKind: "index" for that checkout's index or "ref" for the branch's own ref, which can apply to a branch with no checkout; use those fields rather than assuming an index lock; refused: git would not move the checkout for another reason, such as a merge in progress there; entries with reason dirty or untracked list the files in the way and don't fail the sync); follow next_steps and call stack_sync again. Every tool accepts repo_path (an absolute path inside the repository); otherwise the client's first root, then the server's working directory, is used. stack_view is read-only; stack_submit pushes to the remote, stack_merge merges pull requests on the forge, and stack_sync deletes local branches.` diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index ca4a4c0..0111fb2 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -832,3 +832,35 @@ func TestPop(t *testing.T) { t.Errorf("dirty tree = %+v", te) } } + +func TestCreateInsert(t *testing.T) { + h := newHarness(t) // main -> a -> b, HEAD on b + defer h.assertNoStdout() + gittest.Run(t, h.dir, "switch", "-q", "a") + gittest.WriteFile(t, h.dir, "n.txt", "n") + var cr app.CreateResult + h.call("stack_create", map[string]any{"message": "feat: n", "branch": "n", "staging": "all", "insert": true}, &cr) + if cr.Branch != "n" || cr.Parent != "a" || !cr.Inserted || !slices.Equal(cr.Restacked, []string{"b"}) || h.current() != "n" { + t.Fatalf("create = %+v (on %s)", cr, h.current()) + } + if s, _, _ := h.backend.Graph().StackOf("a"); !slices.Equal(s.Names(), []string{"a", "n", "b"}) { + t.Errorf("order = %v", s.Names()) + } + + // A conflict keeps the result alongside the error. + gittest.WriteFile(t, h.dir, "b.txt", "not b") + res, text := h.call("stack_create", map[string]any{"message": "feat: m", "branch": "m", "staging": "all", "insert": true}, nil) + if !res.IsError || !strings.Contains(text, `"code":"conflict"`) { + t.Fatalf("conflict: %q", text) + } + raw, _ := json.Marshal(res.StructuredContent) + if !strings.Contains(string(raw), `"branch":"m"`) || !strings.Contains(string(raw), `"inserted":true`) { + t.Errorf("structured result on conflict = %s", raw) + } + h.call("stack_abort", nil, nil) + + gittest.Run(t, h.dir, "switch", "-q", "main") + if te := h.toolErr("stack_create", map[string]any{"message": "x", "insert": true}); te.Code != "invalid_args" { + t.Errorf("insert on trunk = %+v", te) + } +} diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 823e395..f646526 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -122,6 +122,7 @@ type createInput struct { Branch string `json:"branch,omitempty" jsonschema:"branch name; derived from the message when omitted"` Staging string `json:"staging,omitempty" jsonschema:"what to stage before committing: all (git add -A), update (git add -u) or none (default)"` UseAI bool `json:"use_ai,omitempty" jsonschema:"let git-stack's own AI draft the branch name from the diff (opt-in; you normally write it yourself)"` + Insert bool `json:"insert,omitempty" jsonschema:"put the branch right above the current one even when that is not the top of the stack, and restack the branches above onto it"` } func (s *Server) create(ctx context.Context, req *mcp.CallToolRequest, in createInput) (*mcp.CallToolResult, app.CreateResult, error) { @@ -137,8 +138,13 @@ func (s *Server) create(ctx context.Context, req *mcp.CallToolRequest, in create return nil, app.CreateResult{}, wrapErr(err) } res, err := a.Create(ctx, repo, app.CreateOptions{ - Name: in.Branch, Message: []string{in.Message}, Staging: staging, UseAI: in.UseAI, + Name: in.Branch, Message: []string{in.Message}, Staging: staging, UseAI: in.UseAI, Insert: in.Insert, }) + if err != nil && res.Branch != "" && errors.Is(err, &stack.Error{Kind: stack.KindConflict}) { + // The branch is in and committed; restacking the ones above hit a + // conflict: keep both, as stack_reorder does. + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: wrapErr(err).Error()}}}, res, nil + } if err != nil { return nil, app.CreateResult{}, wrapErr(err) } @@ -575,8 +581,8 @@ func (s *Server) registerTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_create", Title: "Create a stacked branch", - Description: "Create a new branch on top of the current branch, commit staged changes with the given message, and register it in the stack. From the trunk this starts a new stack. Fails with not_at_top when the current branch is not the top of its stack. If the commit fails (signing_failed, a hook) the branch is removed again and the changes stay staged, so fix the cause and call it again; should removing it fail too, next_steps says what was left behind and how to commit on it.", - Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(false), OpenWorldHint: boolp(false)}, + Description: "Create a new branch on top of the current branch, commit staged changes with the given message, and register it in the stack. From the trunk this starts a new stack. Fails with not_at_top when the current branch is not the top of its stack, unless insert is true: then the branch goes in right above the current one and the branches above are restacked onto it (a conflict comes back as code conflict with the result alongside; stack_continue finishes it, stack_abort puts those branches back and keeps the new branch), and the next stack_submit regroups the stack on GitHub. If the commit fails (signing_failed, a hook) the branch is removed again and the changes stay staged, so fix the cause and call it again; should removing it fail too, next_steps says what was left behind and how to commit on it.", + Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, }, s.create) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_modify",