Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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`

Expand Down
34 changes: 27 additions & 7 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func newCreateCmd(c *cli) *cobra.Command {
useAI bool
noAI bool
noVerify bool
insert bool
)
cmd := &cobra.Command{
Use: "create [name]",
Expand All @@ -22,17 +23,24 @@ 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"

# pick the name yourself, with a second paragraph for the commit body
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 {
Expand All @@ -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
},
Expand All @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions cmd/reshape_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
16 changes: 14 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
117 changes: 106 additions & 11 deletions pkg/app/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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")
Expand All @@ -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).
Expand All @@ -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 <message>"))
return CreateResult{}, a.undoCreate(ctx, repo, name, current, snap, commitError(err, "pass -m <message>"))
}
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.
Expand All @@ -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 })
Expand Down
Loading
Loading