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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Claude Code and Codex if you have them installed**. Each of those is on by defau
change before it happens, and `--uninstall` removes what it recorded installing and nothing else
(that record lives in `git config --global stack.managed*`).

- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|rename|untrack|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom|pr`
- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|rename|untrack|track|pop|restack|continue|abort|undo|submit|sync|up|down|top|bottom|pr`
and the short ones `git c|m|ab|rs|ss|u|d|t|b|co`, written with `git config --global`. Anything that
clashes with a git builtin or an installed command is skipped, and an alias you already have is
only replaced if you pass `--force` or say yes when asked.
Expand Down Expand Up @@ -684,6 +684,46 @@ Gives up on the rebase a restack, modify, reorder or move stopped on and puts ba
operation had already moved, metadata included; after a reorder or move that means the stack order
too.

### `git stack undo`

> Alias: `git undo`

Puts back what the last git-stack command you ran in this worktree changed. Run it again to step
further back, up to 20 commands.

```sh
git create -a -m "Add the retry queue" # oops, that should have been a modify
git undo # back on the branch you were on, changes staged again
git modify
```

It covers create (`--insert` too), modify, absorb, restack, reorder, move, fold, pop, untrack,
init (`track`), sync and a rename that stayed on your machine. Every branch they touched goes back
to where it was (a branch sync or pop deleted comes back, one create made goes away), the stack
metadata is put back exactly, and so is what you had checked out. After create, modify or absorb
the changes they committed come back as staged changes, and after a pop you're back on the popped
branch with nothing staged; after anything else
your uncommitted changes stay as they are, and if one of them is in the way undo refuses before
it moves anything. A branch checked out in another worktree follows too, and the same rule
applies there.

Undo is local only and never touches GitHub. Submit, merge and a rename that renamed the branch
on GitHub change what's there, so a successful one clears the history (putting your branches back to before it would only make them
disagree with GitHub). Each worktree has its own history, so an undo here never reverses what
you or an agent did in another checkout.

If a branch has moved since the command (you committed on top of it, say), undo refuses and names
it. `--force` goes ahead and throws away what changed on those branches since; it still never
touches your uncommitted changes.

To keep the commits it might need from being garbage collected, undo points
`refs/git-stack/undo` at an empty commit whose parents are those commits. That ref shows up in
`git log --all`; it goes away once the history is empty.

Flags:
- `--list` show what can be undone, newest first, without changing anything
- `--force` undo even when branches moved since the command

### `git stack log`

Bare `git stack` runs this.
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ func newRootCmd(c *cli) *cobra.Command {
newRestackCmd(c),
newContinueCmd(c),
newAbortCmd(c),
newUndoCmd(c),
newSubmitCmd(c),
newSyncCmd(c),
newMergeCmd(c),
Expand Down
74 changes: 74 additions & 0 deletions cmd/undo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"time"

"github.com/spf13/cobra"

"github.com/DomBlack/git-stack/pkg/app"
"github.com/DomBlack/git-stack/pkg/ui"
)

func newUndoCmd(c *cli) *cobra.Command {
var list, force bool
cmd := &cobra.Command{
Use: "undo",
Short: "Undo the last git-stack command",
Long: `Puts back what the last git-stack command run in this worktree changed:
branches, the stack metadata and what was checked out. Run it again to step
further back, up to 20 commands. After create, modify or absorb the changes
they committed come back staged; after anything else your local changes stay
as they are.

It covers create, modify, absorb, restack, reorder, move, fold, pop, untrack,
init, sync and a local rename. Undo is local only: submit, merge and a rename
on GitHub change GitHub, so they clear the history.
It refuses when a branch has moved since the command; --force throws that away
(but never your uncommitted changes).`,
Args: cobra.NoArgs,
ValidArgsFunction: completeNothing,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
a, repo, err := c.app(ctx)
if err != nil {
return err
}
res, err := a.Undo(ctx, repo, app.UndoOptions{List: list, Force: force})
if err != nil {
return err
}
renderUndo(c.report(), res, list, time.Now())
return nil
},
}
cmd.Flags().BoolVar(&list, "list", false, "show what can be undone, newest first, without changing anything")
cmd.Flags().BoolVar(&force, "force", false, "undo even when branches moved since, throwing away what changed on them")
return cmd
}

func renderUndo(rep *ui.Reporter, res app.UndoResult, list bool, now time.Time) {
if list {
switch n := len(res.Entries); n {
case 0:
rep.Success("Nothing to undo")
case 1:
rep.Success("1 command can be undone")
default:
rep.Success("%d commands can be undone", n)
}
for _, e := range res.Entries {
rep.Info("%s %s", ui.RelativeTime(e.At, now), e.Command)
}
return
}
for _, n := range res.Notices {
rep.Warn("%s", n)
}
rep.Success("Undid %s (%s)", res.Undone.Command, ui.RelativeTime(res.Undone.At, now))
for _, r := range res.Undone.Restores {
rep.Info("%s", r)
}
if res.Staged {
rep.Info("your changes from it are staged")
}
}
44 changes: 44 additions & 0 deletions cmd/undo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cmd

import (
"strings"
"testing"

"github.com/DomBlack/git-stack/pkg/git/gittest"
)

func TestUndoCommand(t *testing.T) {
dir, run := stackOf3(t)
out, _, err := run("undo", "--list")
if err != nil || !strings.Contains(out, "3 commands can be undone") || !strings.Contains(out, "git stack create") {
t.Errorf("list: %q %v", out, err)
}
out, errOut, err := run("undo")
if err != nil || !strings.Contains(out, "ok: Undid git stack create (just now)") || !strings.Contains(out, "c deleted") ||
!strings.Contains(out, "your changes from it are staged") {
t.Errorf("undo: %q %q %v", out, errOut, err)
}
if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "b" {
t.Errorf("current = %s", cur)
}
if st := gittest.Run(t, dir, "status", "--short"); st != "A c.txt" {
t.Errorf("status = %q", st)
}
gittest.Run(t, dir, "commit", "-q", "-m", "mine")
if _, _, err = run("undo"); err == nil || !strings.Contains(err.Error(), "b moved since git stack create") {
t.Errorf("drift: %v", err)
}
if out, _, err = run("undo", "--force"); err != nil || !strings.Contains(out, "Undid git stack create") {
t.Errorf("force: %q %v", out, err)
}
if _, _, err := run("undo"); err != nil {
t.Fatal(err)
}
out, _, err = run("undo", "--list")
if err != nil || !strings.Contains(out, "Nothing to undo") {
t.Errorf("empty list: %q %v", out, err)
}
if _, _, err = run("undo"); err == nil || !strings.Contains(err.Error(), "nothing to undo yet") {
t.Errorf("empty: %v", err)
}
}
20 changes: 20 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,26 @@ program exits, while the confirm prompt renders an empty final frame and prints
summary line. Both were verified by driving the real prompt under a pty with expect; if the
renderer gets fixed upstream this can go back to the obvious implementation.

**Undo restores snapshots rather than running inverses.** Writing an inverse for every command
(and keeping each one right as the commands change) would be a lot of code to get subtly wrong,
so every covered use case runs inside one wrapper in `pkg/app` that snapshots the branch tips,
gh stack's metadata, the queue of GitHub stacks to dissolve and each worktree's HEAD first, and
records an entry with the after state when it finishes (`<common git dir>/git-stack/undo.json`,
20 entries). The metadata goes through a `Snapshot`/`Restore` pair on the metadata port, so it's
put back byte for byte and still only the adapter writes the file. Commits a command deleted or
rewrote away would otherwise be fair game for gc, so `refs/git-stack/undo` points at an empty tree
commit whose parents are every commit the history might put back; it's rewritten as the history
changes and deleted when it's empty. GitHub is never touched: submit, merge and a rename on GitHub are the only
things that change it, and once they have, putting local branches back would just make the two
disagree, so a successful one clears the history instead. The worktree undo runs in follows its
branch with `reset --soft` after the commands that committed your changes (create, modify, absorb,
and pop's reverse, which gives the popped branch its checkout back)
so they come back staged, and with `reset --keep` after the ones that rewrote history, so local
changes come along; every other worktree uses `--keep`, and everything that could refuse is
checked before anything moves. Entries remember their worktree and undo only looks at its own,
so an agent working in one checkout can't have its work undone from another; the drift check
(every ref must still be where the command left it, unless `--force`) still sees the others.

**No task runner, no fuzzy matching dependency.** The whole check is `go build`, `go vet`,
`golangci-lint run`, `go test` (recorded in AGENTS.md and CI), and the picker's filter is a
small native subsequence matcher.
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `stack_pop` | Delete the current branch and take it out of its stack, leaving its changes staged on its parent; the branches above are restacked onto the parent first, and its open PR is closed by the next `stack_submit`; refuses with nothing changed when tracked files have changes, the branch isn't on its parent or the parent is checked out in another worktree | | destructive |
| `stack_continue` | Finish an interrupted restack, modify, reorder, move or pop once the conflicts are resolved and git added | `stage_all` | destructive |
| `stack_abort` | Give up an interrupted restack and put every moved branch back (and, after a reorder or move, the old stack order) | | destructive |
| `stack_undo` | Undo the newest command run in this worktree that changed local state (create, modify, absorb, restack, reorder, move, fold, pop, untrack, init, sync, a local rename); call again to step further back, up to 20. After create, modify or absorb the changes come back staged (`staged: true`). A successful `stack_submit` or `stack_merge` clears the history. Refuses with `invalid_args` when nothing is left, when a branch moved since (`force` throws that away; uncommitted changes always stay) or when local changes are in the way, with nothing changed | `list` (the history with what each would put back, changing nothing), `force` (only with the user's say so) | 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 (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 |
Expand Down
7 changes: 7 additions & 0 deletions pkg/app/absorb.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ const syncSteps = "run git stack sync to tidy the stack's metadata"
// last changed those lines, rewrites that commit and everything above it
// up to the current branch, and restacks the branches above.
func (a *App) Absorb(ctx context.Context, repo git.Repo, o AbsorbOptions) (AbsorbResult, error) {
return undoableResult(a, ctx, repo, absorbCommand, undoSoft, func(ctx context.Context) (AbsorbResult, error) {
return a.absorb(ctx, repo, o)
})
}

// absorb is Absorb's body.
func (a *App) absorb(ctx context.Context, repo git.Repo, o AbsorbOptions) (AbsorbResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return AbsorbResult{}, err
}
Expand Down
14 changes: 14 additions & 0 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/DomBlack/git-stack/pkg/git"
"github.com/DomBlack/git-stack/pkg/git/gittest"
"github.com/DomBlack/git-stack/pkg/stack"
"github.com/DomBlack/git-stack/pkg/stack/stacktest"
)

// memMeta is an in-memory stack.Metadata.
Expand All @@ -29,6 +30,19 @@ func (m memMeta) Update(_ context.Context, _ git.Repo, fn func(*stack.Graph) err
return nil
}

func (m memMeta) Snapshot(context.Context, git.Repo) (stack.Snapshot, error) {
return stacktest.SnapshotGraph(m.g)
}

func (m memMeta) Restore(_ context.Context, _ git.Repo, s stack.Snapshot) error {
g, err := stacktest.RestoreGraph(s)
if err != nil {
return err
}
*m.g = *g
return nil
}

// fixture builds main -> a -> b -> c plus an untracked branch "loose" where
// b has been amended so c needs a restack.
func fixture(t *testing.T) (*app.App, git.Repo, string) {
Expand Down
7 changes: 7 additions & 0 deletions pkg/app/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ const undoTimeout = 30 * time.Second
// 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) {
return undoableResult(a, ctx, repo, "git stack create", undoSoft, func(ctx context.Context) (CreateResult, error) {
return a.create(ctx, repo, o)
})
}

// create is Create's body.
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 Down
16 changes: 16 additions & 0 deletions pkg/app/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package app

import "github.com/DomBlack/git-stack/pkg/git"

// UndoHistory lists the undo history, newest first, as "command (mode)".
func UndoHistory(repo git.Repo) []string {
l, err := loadUndoLog(repo)
if err != nil {
panic(err)
}
out := make([]string, len(l.Entries))
for i, e := range l.Entries {
out[i] = e.Command + " (" + string(e.Mode) + ")"
}
return out
}
11 changes: 11 additions & 0 deletions pkg/app/fold.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ const foldCommand = "git stack fold"
// its parent, so nothing needs restacking: the branches above already have
// every commit involved and only their recorded parent changes.
func (a *App) Fold(ctx context.Context, repo git.Repo, o FoldOptions) (FoldResult, error) {
command := foldCommand
if o.Keep {
command += " --keep"
}
return undoableResult(a, ctx, repo, command, undoKeep, func(ctx context.Context) (FoldResult, error) {
return a.fold(ctx, repo, o)
})
}

// fold is Fold's body.
func (a *App) fold(ctx context.Context, repo git.Repo, o FoldOptions) (FoldResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return FoldResult{}, err
}
Expand Down
7 changes: 7 additions & 0 deletions pkg/app/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ type InitResult struct {
// Init records a new stack from branches that already exist. It changes
// no ref and checks nothing out.
func (a *App) Init(ctx context.Context, repo git.Repo, o InitOptions) (InitResult, error) {
return undoableResult(a, ctx, repo, "git stack init", undoKeep, func(ctx context.Context) (InitResult, error) {
return a.initStack(ctx, repo, o)
})
}

// initStack is Init's body.
func (a *App) initStack(ctx context.Context, repo git.Repo, o InitOptions) (InitResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return InitResult{}, err
}
Expand Down
7 changes: 6 additions & 1 deletion pkg/app/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,19 @@ func (a *App) Merge(ctx context.Context, repo git.Repo, o MergeOptions) (MergeRe
if err != nil {
return res, err
}
// GitHub has changed, so nothing before this can be undone, and the
// sync below isn't recorded either.
if cerr := a.clearUndo(ctx, repo); cerr != nil {
a.d.Log.Warn("could not clear the undo history", "err", cerr)
}
if res.Status == forge.MergeEnqueued {
res.Notices = append(res.Notices, fmt.Sprintf("%s has a merge queue; the pull requests land when it gets to them, then git stack sync tidies up", s.Trunk))
return res, nil
}
if o.NoSync {
return res, nil
}
sync, err := a.Sync(ctx, repo, SyncOptions{})
sync, err := a.Sync(withoutUndo(ctx), repo, SyncOptions{})
res.Sync = &sync
return res, err
}
Expand Down
11 changes: 11 additions & 0 deletions pkg/app/modify.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ func (a *App) Modify(ctx context.Context, repo git.Repo, o ModifyOptions) (Modif
_, err := a.restackAbort(ctx, repo)
return ModifyResult{Branch: branchAfter(ctx, a, repo)}, err
}
command := "git stack modify"
if o.NewCommit {
command += " --commit"
}
return undoableResult(a, ctx, repo, command, undoSoft, func(ctx context.Context) (ModifyResult, error) {
return a.modify(ctx, repo, o)
})
}

// modify is Modify's body once it isn't resuming a restack.
func (a *App) modify(ctx context.Context, repo git.Repo, o ModifyOptions) (ModifyResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return ModifyResult{}, err
}
Expand Down
15 changes: 15 additions & 0 deletions pkg/app/move.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ type MoveResult struct {

const moveCommand = "git stack move"

// moveCommandLine is the move as undo lists it.
func moveCommandLine(o MoveOptions) string {
if o.Only {
return moveCommand + " --only"
}
return moveCommand
}

// MoveDestinations lists where source could go: every trunk, then the top
// of every stack other than source's own, in graph order.
func (a *App) MoveDestinations(graph *stack.Graph, source string) []MoveDestination {
Expand All @@ -74,6 +82,13 @@ func (a *App) MoveDestinations(graph *stack.Graph, source string) []MoveDestinat
// another stack or on a trunk as a new stack, then restacks what moved and
// what was left behind. Nothing is checked out.
func (a *App) Move(ctx context.Context, repo git.Repo, o MoveOptions) (MoveResult, error) {
return undoableResult(a, ctx, repo, moveCommandLine(o), undoKeep, func(ctx context.Context) (MoveResult, error) {
return a.move(ctx, repo, o)
})
}

// move is Move's body.
func (a *App) move(ctx context.Context, repo git.Repo, o MoveOptions) (MoveResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return MoveResult{}, err
}
Expand Down
Loading
Loading