diff --git a/README.md b/README.md index f60ac91..51f6da1 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/cmd/root.go b/cmd/root.go index affc471..c8b17ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -332,6 +332,7 @@ func newRootCmd(c *cli) *cobra.Command { newRestackCmd(c), newContinueCmd(c), newAbortCmd(c), + newUndoCmd(c), newSubmitCmd(c), newSyncCmd(c), newMergeCmd(c), diff --git a/cmd/undo.go b/cmd/undo.go new file mode 100644 index 0000000..4fcd384 --- /dev/null +++ b/cmd/undo.go @@ -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") + } +} diff --git a/cmd/undo_test.go b/cmd/undo_test.go new file mode 100644 index 0000000..aaa67c4 --- /dev/null +++ b/cmd/undo_test.go @@ -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) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0c6ace8..e09f616 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 (`/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. diff --git a/docs/mcp.md b/docs/mcp.md index 2aeff33..6fad53a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 | diff --git a/pkg/app/absorb.go b/pkg/app/absorb.go index de99a7c..6896b01 100644 --- a/pkg/app/absorb.go +++ b/pkg/app/absorb.go @@ -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 } diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index 067867c..7f35e01 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -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. @@ -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) { diff --git a/pkg/app/create.go b/pkg/app/create.go index 4d15f96..6912575 100644 --- a/pkg/app/create.go +++ b/pkg/app/create.go @@ -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") } diff --git a/pkg/app/export_test.go b/pkg/app/export_test.go new file mode 100644 index 0000000..a6b23b7 --- /dev/null +++ b/pkg/app/export_test.go @@ -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 +} diff --git a/pkg/app/fold.go b/pkg/app/fold.go index 5f11550..c3c790b 100644 --- a/pkg/app/fold.go +++ b/pkg/app/fold.go @@ -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 } diff --git a/pkg/app/init.go b/pkg/app/init.go index d40e2e3..cf8f1a4 100644 --- a/pkg/app/init.go +++ b/pkg/app/init.go @@ -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 } diff --git a/pkg/app/merge.go b/pkg/app/merge.go index 5dd26d0..0a72e43 100644 --- a/pkg/app/merge.go +++ b/pkg/app/merge.go @@ -136,6 +136,11 @@ 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 @@ -143,7 +148,7 @@ func (a *App) Merge(ctx context.Context, repo git.Repo, o MergeOptions) (MergeRe 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 } diff --git a/pkg/app/modify.go b/pkg/app/modify.go index 5c6b942..a899428 100644 --- a/pkg/app/modify.go +++ b/pkg/app/modify.go @@ -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 } diff --git a/pkg/app/move.go b/pkg/app/move.go index 134c8e2..c89b54e 100644 --- a/pkg/app/move.go +++ b/pkg/app/move.go @@ -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 { @@ -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 } diff --git a/pkg/app/mutate_test.go b/pkg/app/mutate_test.go index defbc0a..d4642d4 100644 --- a/pkg/app/mutate_test.go +++ b/pkg/app/mutate_test.go @@ -17,6 +17,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" ) // fakeBackend implements Metadata and Tracker in memory, doing the git @@ -41,6 +42,19 @@ func (f *fakeBackend) Update(_ context.Context, _ git.Repo, fn func(*stack.Graph return nil } +func (f *fakeBackend) Snapshot(context.Context, git.Repo) (stack.Snapshot, error) { + return stacktest.SnapshotGraph(f.graph) +} + +func (f *fakeBackend) Restore(_ context.Context, _ git.Repo, s stack.Snapshot) error { + g, err := stacktest.RestoreGraph(s) + if err != nil { + return err + } + f.graph = g + return nil +} + func (f *fakeBackend) InitStack(ctx context.Context, repo git.Repo, trunk string, branches []string) error { s := stack.Stack{Trunk: trunk} prev := trunk diff --git a/pkg/app/pop.go b/pkg/app/pop.go index 81869ce..ee2b4c7 100644 --- a/pkg/app/pop.go +++ b/pkg/app/pop.go @@ -33,6 +33,15 @@ const popComment = "Closed by git stack pop; its changes went back into the work // the parent first, while the popped branch is still checked out, and its // pull request is queued to close at the next submit. func (a *App) Pop(ctx context.Context, repo git.Repo) (PopResult, error) { + // Undo puts the branch back and points HEAD at it again, so the changes + // pop left staged become its checkout once more. + return undoableResult(a, ctx, repo, popCommand, undoSoft, func(ctx context.Context) (PopResult, error) { + return a.pop(ctx, repo) + }) +} + +// pop is Pop's body. +func (a *App) pop(ctx context.Context, repo git.Repo) (PopResult, error) { if err := a.noRebaseActive(ctx, repo); err != nil { return PopResult{}, err } diff --git a/pkg/app/rename.go b/pkg/app/rename.go index 8e04ba9..a4ddc5a 100644 --- a/pkg/app/rename.go +++ b/pkg/app/rename.go @@ -39,6 +39,25 @@ const renameCommand = "git stack rename" // leaves everything here as it was. With Local only this repository // changes. func (a *App) Rename(ctx context.Context, repo git.Repo, o RenameOptions) (RenameResult, error) { + command := renameCommand + if o.Local { + command += " --local" + } + res, err := undoableResult(a, ctx, repo, command, undoKeep, func(ctx context.Context) (RenameResult, error) { + return a.rename(ctx, repo, o) + }) + if err == nil && res.Remote != "" { + // GitHub has the new name now, and undo only ever puts local state + // back, so like a submit this is as far back as undo can go. + if cerr := a.clearUndo(ctx, repo); cerr != nil { + a.d.Log.Warn("could not clear the undo history", "err", cerr) + } + } + return res, err +} + +// rename is Rename's body. +func (a *App) rename(ctx context.Context, repo git.Repo, o RenameOptions) (RenameResult, error) { if err := a.noRebaseActive(ctx, repo); err != nil { return RenameResult{}, err } diff --git a/pkg/app/reorder.go b/pkg/app/reorder.go index 8ee7997..4319f5d 100644 --- a/pkg/app/reorder.go +++ b/pkg/app/reorder.go @@ -40,6 +40,13 @@ const reorderCommand = "git stack reorder" // Reorder changes the order of the branches in the current stack and // restacks it. Nothing is checked out. func (a *App) Reorder(ctx context.Context, repo git.Repo, o ReorderOptions) (ReorderResult, error) { + return undoableResult(a, ctx, repo, reorderCommand, undoKeep, func(ctx context.Context) (ReorderResult, error) { + return a.reorder(ctx, repo, o) + }) +} + +// reorder is Reorder's body. +func (a *App) reorder(ctx context.Context, repo git.Repo, o ReorderOptions) (ReorderResult, error) { if err := a.noRebaseActive(ctx, repo); err != nil { return ReorderResult{}, err } diff --git a/pkg/app/reshape.go b/pkg/app/reshape.go index 45e95e6..9d362cd 100644 --- a/pkg/app/reshape.go +++ b/pkg/app/reshape.go @@ -336,7 +336,7 @@ func (a *App) reshape(ctx context.Context, repo git.Repo, command, headline stri } return RestackResult{}, err } - run := &restackRun{repo: repo, command: command, originalBranch: current, reshape: snap, finish: e.finish} + run := &restackRun{repo: repo, command: command, originalBranch: current, reshape: snap, finish: e.finish, undo: undoFrom(ctx)} res := RestackResult{Scope: stack.ScopeUpstack} if len(e.segments) == 0 { if err := a.finishReshape(ctx, run, &res); err != nil { @@ -404,7 +404,7 @@ func (a *App) saveStopped(run *restackRun, err error) error { e := asStackError(err) st := &restackState{ Command: run.command, OriginalBranch: run.originalBranch, Trunk: run.trunk, - Moved: run.moved, Then: run.then, Reshape: run.reshape, Finish: run.finish, + Moved: run.moved, Then: run.then, Reshape: run.reshape, Finish: run.finish, Undo: run.undo, } if serr := saveRestackState(run.repo, st); serr != nil { return e.WithSteps(fmt.Sprintf("the new shape was kept but could not be saved for git stack continue or abort (%v); git stack restack finishes it", serr)) diff --git a/pkg/app/restack.go b/pkg/app/restack.go index dbe3ef5..858691f 100644 --- a/pkg/app/restack.go +++ b/pkg/app/restack.go @@ -60,7 +60,9 @@ func (a *App) Restack(ctx context.Context, repo git.Repo, o RestackOptions) (Res case o.Abort: return a.restackAbort(ctx, repo) } - return a.restack(ctx, repo, o, restackCommand, "") + return undoableResult(a, ctx, repo, restackCommand, undoKeep, func(ctx context.Context) (RestackResult, error) { + return a.restack(ctx, repo, o, restackCommand, "") + }) } // restackRun is one restack operation in flight; on a conflict it is what @@ -76,6 +78,9 @@ type restackRun struct { then []restackSegment reshape *reshapeSnapshot finish *reshapeFinish + // undo is the covered command's before state, saved with the run so + // continue can record it. + undo *undoSnapshot } // restack is the shared body of Restack and modify's restack. command @@ -124,7 +129,7 @@ func (a *App) restack(ctx context.Context, repo git.Repo, o RestackOptions, comm if headline == "" { headline = "Restacking " + joinNames(res.Branches) } - run := &restackRun{repo: repo, command: command, trunk: s.Trunk, stackName: s.Bottom(), originalBranch: current} + run := &restackRun{repo: repo, command: command, trunk: s.Trunk, stackName: s.Bottom(), originalBranch: current, undo: undoFrom(ctx)} err = a.progress(ctx, PhaseRestack, headline, func(ctx context.Context) error { return a.restackBranches(ctx, run, slices.Clone(s.Branches[lo:hi]), parent, &res) }) @@ -240,7 +245,7 @@ func (a *App) startConflictRebase(ctx context.Context, run *restackRun, p *resta Conflict: c.Branch, Onto: c.Onto, NewBase: p.conflictNewBase, ConflictTip: local[c.Branch].Head, ConflictOldBase: oldBase, Remaining: remaining, Moved: run.moved, - Then: run.then, Reshape: run.reshape, Finish: run.finish, + Then: run.then, Reshape: run.reshape, Finish: run.finish, Undo: run.undo, } if err := saveRestackState(run.repo, run.state); err != nil { return err @@ -530,7 +535,7 @@ func (a *App) restackContinue(ctx context.Context, repo git.Repo, stageAll bool) WithSteps("git stack abort to put the moved branches back, then " + st.Command + " again") } earlier := slices.Clone(st.Moved) - run := &restackRun{repo: repo, command: st.Command, trunk: st.Trunk, originalBranch: st.OriginalBranch, moved: st.Moved, state: st, then: st.Then, reshape: st.Reshape, finish: st.Finish} + run := &restackRun{repo: repo, command: st.Command, trunk: st.Trunk, originalBranch: st.OriginalBranch, moved: st.Moved, state: st, then: st.Then, reshape: st.Reshape, finish: st.Finish, undo: st.Undo} if g, err := a.d.Meta.Load(ctx, repo); err == nil { if s, _, ok := g.StackOf(st.Conflict); ok { run.stackName = s.Bottom() @@ -571,6 +576,11 @@ func (a *App) restackContinue(ctx context.Context, repo git.Repo, stageAll bool) }) if err == nil { res.Moved = withEarlierMoves(earlier, res.Moved, run.stackName) + if st.Undo != nil { + if rerr := a.recordUndo(ctx, repo, *st.Undo); rerr != nil { + a.d.Log.Warn("could not record the command for undo", "command", st.Undo.Command, "err", rerr) + } + } return res, nil } cur, lerr := loadRestackState(repo) diff --git a/pkg/app/restack_state.go b/pkg/app/restack_state.go index cdff340..1ca29a6 100644 --- a/pkg/app/restack_state.go +++ b/pkg/app/restack_state.go @@ -48,6 +48,10 @@ type restackState struct { // Finish is what the reshape still has to do once every branch is // restacked: a pop's move of HEAD and delete of its branch. Finish *reshapeFinish `json:"finish,omitempty"` + // Undo is the before state of the covered command that started this + // run; continue records it for undo once the command finishes, abort + // drops it. + Undo *undoSnapshot `json:"undo,omitempty"` } // branches are the branches the state still has to restack, the diff --git a/pkg/app/restack_state_test.go b/pkg/app/restack_state_test.go index 6d44b71..69914ee 100644 --- a/pkg/app/restack_state_test.go +++ b/pkg/app/restack_state_test.go @@ -12,6 +12,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" ) func TestRestackStateRoundTrip(t *testing.T) { @@ -79,6 +80,19 @@ func (m *memMeta) Update(_ context.Context, _ git.Repo, fn func(*stack.Graph) er return fn(m.graph) } +func (m *memMeta) Snapshot(context.Context, git.Repo) (stack.Snapshot, error) { + return stacktest.SnapshotGraph(m.graph) +} + +func (m *memMeta) Restore(_ context.Context, _ git.Repo, s stack.Snapshot) error { + g, err := stacktest.RestoreGraph(s) + if err != nil { + return err + } + m.graph = g + return nil +} + // stateRepo is main -> b in a real repo, with b tracked. func stateRepo(t *testing.T) (*App, git.Repo, string) { t.Helper() diff --git a/pkg/app/submit.go b/pkg/app/submit.go index c39d82f..effb220 100644 --- a/pkg/app/submit.go +++ b/pkg/app/submit.go @@ -90,6 +90,19 @@ type ClosedPR struct { // 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) { + res, err := a.submit(ctx, repo, o) + if err == nil && !o.DryRun { + // GitHub has the stack now; undoing what came before would only + // make the local branches disagree with it. + if cerr := a.clearUndo(ctx, repo); cerr != nil { + a.d.Log.Warn("could not clear the undo history", "err", cerr) + } + } + return res, err +} + +// submit is Submit's body. +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") } diff --git a/pkg/app/sync.go b/pkg/app/sync.go index 6cf6a91..92879cd 100644 --- a/pkg/app/sync.go +++ b/pkg/app/sync.go @@ -190,6 +190,13 @@ func (st *syncState) isDirty(ctx context.Context, g *git.Client, path string) bo // remote advanced and restacks every stack, the way gt sync does. Nothing is // pushed; submit does that. func (a *App) Sync(ctx context.Context, repo git.Repo, o SyncOptions) (SyncResult, error) { + return undoableResult(a, ctx, repo, "git stack sync", undoKeep, func(ctx context.Context) (SyncResult, error) { + return a.sync(ctx, repo, o) + }) +} + +// sync is Sync's body. +func (a *App) sync(ctx context.Context, repo git.Repo, o SyncOptions) (SyncResult, error) { var res SyncResult st, err := a.gatherSync(ctx, repo) if err != nil { diff --git a/pkg/app/undo.go b/pkg/app/undo.go new file mode 100644 index 0000000..051370a --- /dev/null +++ b/pkg/app/undo.go @@ -0,0 +1,344 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "slices" + "time" + + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// UndoOptions controls Undo. +type UndoOptions struct { + // List shows this worktree's history without changing anything. + List bool + // Force undoes even when branches moved since the command, throwing + // away what happened on them. Working tree changes are never thrown + // away. + Force bool +} + +// UndoEntryView is one command undo can take back. +type UndoEntryView struct { + Command string `json:"command"` + At time.Time `json:"at"` + // Restores lists what undoing it puts back, one short line each. + Restores []string `json:"restores"` +} + +// UndoResult reports what Undo did, or with List what it could do. +type UndoResult struct { + Undone *UndoEntryView `json:"undone,omitempty"` + // Entries is the history, newest first (List only). + Entries []UndoEntryView `json:"entries,omitempty"` + // Staged is true when the undone command's changes are back as staged + // changes (create, modify, absorb). + Staged bool `json:"staged,omitempty"` + Notices []string `json:"notices,omitempty"` +} + +// Undo reverses the newest command recorded in this worktree: every branch +// it covered goes back to its tip from before, the metadata and the GitHub +// queue are put back byte for byte, and checked out branches follow, softly +// (changes come back staged) or keeping local changes, by the command's +// mode. Nothing moves when anything would refuse. +func (a *App) Undo(ctx context.Context, repo git.Repo, o UndoOptions) (UndoResult, error) { + if o.List { + l, err := loadUndoLog(repo) + if err != nil { + return UndoResult{}, err + } + var res UndoResult + for _, e := range l.Entries { + if e.Worktree == repo.TopLevel { + res.Entries = append(res.Entries, entryView(e, repo.TopLevel)) + } + } + return res, nil + } + if err := a.noRebaseActive(ctx, repo); err != nil { + return UndoResult{}, err + } + var res UndoResult + err := a.editUndoLog(ctx, repo, func(l *undoLog) error { + i := slices.IndexFunc(l.Entries, func(e undoEntry) bool { return e.Worktree == repo.TopLevel }) + if i < 0 { + if l.Cleared { + return stack.New(stack.KindInvalidArgs, "nothing to undo since the last submit or merge") + } + return stack.New(stack.KindInvalidArgs, "nothing to undo yet") + } + e := l.Entries[i] + v := entryView(e, repo.TopLevel) + staged, notices, err := a.undoEntry(ctx, repo, e, o.Force) + if err != nil { + return err + } + res.Undone, res.Staged, res.Notices = &v, staged, notices + l.Entries = slices.Delete(l.Entries, i, i+1) + return nil + }) + return res, err +} + +// worktreeMove is how one worktree follows its branch back. +type worktreeMove struct { + path string + mode undoMode + fromBranch string // "" when detached + from string // HEAD now + branch, tip string // what to check out, and where + switchBranch bool +} + +// undoEntry puts e's before state back. It checks everything that could +// refuse first, so a refusal changes nothing. +func (a *App) undoEntry(ctx context.Context, repo git.Repo, e undoEntry, force bool) (staged bool, notices []string, err error) { + local, err := a.localBranches(ctx, repo) + if err != nil { + return false, nil, err + } + if !force { + var moved []string + for _, n := range slices.Sorted(maps.Keys(e.After.Refs)) { + if local[n].Head != e.After.Refs[n] { + moved = append(moved, n) + } + } + if len(moved) > 0 { + return false, nil, stack.Newf(stack.KindInvalidArgs, "%s moved since %s, so undoing it would lose that", namesUpTo(moved, 3), e.Command). + WithSteps("git stack undo --force throws away what changed on them since (your uncommitted changes stay)") + } + } + moves, err := a.planWorktreeMoves(ctx, repo, e, local) + if err != nil { + return false, nil, err + } + + // Branches not checked out anywhere move in one transaction; the ones + // that are move with their worktree, or after it has left them. + var updates []git.RefUpdate + var later []string + for _, n := range slices.Sorted(maps.Keys(e.Before.Refs)) { + want, cur := e.Before.Refs[n], local[n].Head + if want == cur { + continue + } + if local[n].Worktree != "" { + later = append(later, n) + continue + } + if want == "" { + updates = append(updates, git.RefUpdate{Ref: "refs/heads/" + n, Old: cur}) + } else { + updates = append(updates, git.RefUpdate{Ref: "refs/heads/" + n, New: want, Old: cur}) + } + } + if err := a.d.Git.UpdateRefs(ctx, repo, updates); err != nil { + return false, nil, stack.New(stack.KindInvalidArgs, "a branch moved while undo was putting it back, so nothing was changed"). + WithSteps("run git stack undo again").WithCause(err) + } + if err := a.d.Meta.Restore(ctx, repo, e.Before.Meta); err != nil { + return false, nil, err + } + if err := restoreQueue(repo, e.Before.Queue); err != nil { + return false, nil, err + } + for _, m := range moves { + wr := worktreeRepo(repo, m.path) + switch m.mode { + case undoSoft: + if m.switchBranch { + err = a.d.Git.PointHEAD(ctx, wr, m.branch) + } + if err == nil { + err = a.d.Git.ResetSoft(ctx, wr, m.tip) + } + default: + if m.switchBranch { + err = a.d.Git.Switch(ctx, wr, m.branch) + } + if err == nil { + err = a.d.Git.ResetKeep(ctx, wr, m.tip) + } + } + if err != nil { + return false, nil, fmt.Errorf("putting %s back in %s: %w", m.branch, shortPath(m.path), err) + } + if m.path == repo.TopLevel && m.mode == undoSoft { + if staged, err = a.d.Git.HasStagedChanges(ctx, wr); err != nil { + return false, nil, err + } + } + } + + // The branches a worktree has just left (or one undo couldn't plan for). + if len(later) > 0 { + now, err := a.localBranches(ctx, repo) + if err != nil { + return staged, nil, err + } + updates = updates[:0] + for _, n := range later { + want, cur := e.Before.Refs[n], now[n].Head + switch { + case want == cur: + case now[n].Worktree != "": + notices = append(notices, fmt.Sprintf("%s is checked out in %s and was left where it is", n, shortPath(now[n].Worktree))) + case want == "": + updates = append(updates, git.RefUpdate{Ref: "refs/heads/" + n, Old: cur}) + default: + updates = append(updates, git.RefUpdate{Ref: "refs/heads/" + n, New: want, Old: cur}) + } + } + if err := a.d.Git.UpdateRefs(ctx, repo, updates); err != nil { + return staged, notices, err + } + } + return staged, notices, nil +} + +// planWorktreeMoves works out how each worktree follows its branch back, +// and refuses, before anything moves, when local changes in a worktree that +// keeps them are in the way. +func (a *App) planWorktreeMoves(ctx context.Context, repo git.Repo, e undoEntry, local map[string]git.Branch) ([]worktreeMove, error) { + wts, err := a.d.Git.Worktrees(ctx, repo) + if err != nil { + return nil, err + } + var moves []worktreeMove + for _, w := range wts { + if w.Bare || w.Prunable { + continue + } + branch := w.Branch + if i := slices.IndexFunc(e.Before.Worktrees, func(b undoWorktree) bool { return b.Path == w.Path }); i >= 0 { + branch = e.Before.Worktrees[i].Branch + } + if branch == "" { + continue // detached before: nothing to put back + } + tip, ok := e.Before.Refs[branch] + if !ok { + tip = local[branch].Head + } + if tip == "" || (branch == w.Branch && tip == w.Head) { + continue + } + m := worktreeMove{path: w.Path, mode: undoKeep, fromBranch: w.Branch, from: w.Head, branch: branch, tip: tip, switchBranch: branch != w.Branch} + if w.Path == repo.TopLevel { + m.mode = e.Mode + } + if m.mode == undoKeep { + if err := a.checkKeepMove(ctx, repo, m); err != nil { + return nil, err + } + } + moves = append(moves, m) + } + return moves, nil +} + +// checkKeepMove refuses a move whose reset --keep (or switch) would fail +// because of local changes or untracked files in the way. +func (a *App) checkKeepMove(ctx context.Context, repo git.Repo, m worktreeMove) error { + wr := worktreeRepo(repo, m.path) + changes, err := a.d.Git.LocalChanges(ctx, wr) + if err != nil { + return err + } + var clash []string + if len(changes) > 0 { + changed, err := a.d.Git.ChangedPaths(ctx, repo, m.from, m.tip) + if err != nil { + return err + } + for _, f := range changes { + if slices.Contains(changed, f) { + clash = append(clash, f) + } + } + } + untracked, err := a.untrackedInTheWay(ctx, repo, m.path, m.from, m.tip) + if err != nil { + return err + } + clash = append(clash, untracked...) + if len(clash) == 0 { + return nil + } + where := "here" + if m.path != repo.TopLevel { + where = "in " + shortPath(m.path) + } + return stack.Newf(stack.KindInvalidArgs, "putting %s back would overwrite your changes to %s %s", m.branch, namesUpTo(clash, 3), where). + WithSteps("commit or stash them, then git stack undo again") +} + +// restoreQueue writes remote-stacks.json back as it was, or removes it. +func restoreQueue(repo git.Repo, data []byte) error { + p := remoteStacksPath(repo) + if len(data) == 0 { + if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + if cur, err := os.ReadFile(p); err == nil && bytes.Equal(cur, data) { + return nil + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, p) +} + +// entryView describes e for the history and the result: what undoing it +// puts back, one short line each, from the worktree at top's point of view. +func entryView(e undoEntry, top string) UndoEntryView { + v := UndoEntryView{Command: e.Command, At: e.At} + for _, n := range slices.Sorted(maps.Keys(e.Before.Refs)) { + before, after := e.Before.Refs[n], e.After.Refs[n] + switch { + case before == after: + case before == "": + v.Restores = append(v.Restores, n+" deleted") + case after == "": + v.Restores = append(v.Restores, n+" recreated") + default: + v.Restores = append(v.Restores, n+" moved back") + } + } + if len(v.Restores) == 0 && !maps.EqualFunc(e.Before.Meta.Files, e.After.Meta.Files, bytes.Equal) { + v.Restores = append(v.Restores, "the stack metadata put back") + } + if !bytes.Equal(e.Before.Queue, e.After.Queue) { + v.Restores = append(v.Restores, "the stacks queued for GitHub put back") + } + bi := slices.IndexFunc(e.Before.Worktrees, func(w undoWorktree) bool { return w.Path == top }) + ai := slices.IndexFunc(e.After.Worktrees, func(w undoWorktree) bool { return w.Path == top }) + if bi >= 0 && ai >= 0 && e.Before.Worktrees[bi].Branch != "" && e.Before.Worktrees[bi].Branch != e.After.Worktrees[ai].Branch { + v.Restores = append(v.Restores, "back on "+e.Before.Worktrees[bi].Branch) + } + return v +} + +// namesUpTo joins up to n names, then "and N more". +func namesUpTo(names []string, n int) string { + if len(names) <= n { + return joinNames(names) + } + return fmt.Sprintf("%s and %d more", joinNames(names[:n]), len(names)-n) +} diff --git a/pkg/app/undo_lock_other.go b/pkg/app/undo_lock_other.go new file mode 100644 index 0000000..b6ed65e --- /dev/null +++ b/pkg/app/undo_lock_other.go @@ -0,0 +1,19 @@ +//go:build !unix + +package app + +import ( + "context" + "os" + "time" +) + +// lockFile on platforms without flock only makes sure the lock file exists; +// we don't ship for these platforms. +func lockFile(_ context.Context, path string, _ time.Duration) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + return func() { _ = f.Close() }, nil +} diff --git a/pkg/app/undo_lock_unix.go b/pkg/app/undo_lock_unix.go new file mode 100644 index 0000000..7b99c20 --- /dev/null +++ b/pkg/app/undo_lock_unix.go @@ -0,0 +1,47 @@ +//go:build unix + +package app + +import ( + "context" + "errors" + "os" + "syscall" + "time" + + "github.com/DomBlack/git-stack/pkg/stack" +) + +// lockFile takes an exclusive flock on path, waiting up to wait. The +// returned function releases it. +func lockFile(ctx context.Context, path string, wait time.Duration) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + deadline := time.Now().Add(wait) + for { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil + } + if !errors.Is(err, syscall.EWOULDBLOCK) { + _ = f.Close() + return nil, err + } + if time.Now().After(deadline) { + _ = f.Close() + return nil, stack.New(stack.KindLocked, "the undo history is locked by another git stack command"). + WithSteps("wait for it to finish and retry") + } + select { + case <-ctx.Done(): + _ = f.Close() + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/pkg/app/undo_log.go b/pkg/app/undo_log.go new file mode 100644 index 0000000..9c543c3 --- /dev/null +++ b/pkg/app/undo_log.go @@ -0,0 +1,189 @@ +package app + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "time" + + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// undoMode is how the worktree undo runs in follows its branch back. +type undoMode string + +const ( + // undoSoft is for the commands that turned working tree changes into + // commits (create, modify, absorb): HEAD goes back with reset --soft, + // so what they committed comes back staged. + undoSoft undoMode = "soft" + // undoKeep is for the commands that rewrote history (restack, reorder, + // move, init, sync): the branch goes back with reset --keep, so local + // changes come along or the undo refuses. + undoKeep undoMode = "keep" +) + +// undoLimit is how many commands the history keeps, across worktrees. +const undoLimit = 20 + +// undoRef keeps every commit an entry's before state mentions reachable, +// so garbage collection never takes what undo might need to put back. +const undoRef = "refs/git-stack/undo" + +// undoState is everything undo puts back, as it was at one moment. +type undoState struct { + // Refs maps each branch to its tip, "" when it doesn't exist. + Refs map[string]string `json:"refs"` + Meta stack.Snapshot `json:"meta"` + // Queue is remote-stacks.json as stored, nil when it doesn't exist. + Queue []byte `json:"queue,omitempty"` + Worktrees []undoWorktree `json:"worktrees,omitempty"` +} + +// undoWorktree is a worktree and what it had checked out. +type undoWorktree struct { + Path string `json:"path"` + // Branch is "" when HEAD was detached. + Branch string `json:"branch,omitempty"` + Head string `json:"head"` +} + +// undoEntry is one command that can be undone. +type undoEntry struct { + Command string `json:"command"` + At time.Time `json:"at"` + // Worktree is where the command ran; only an undo run there sees it. + Worktree string `json:"worktree"` + Mode undoMode `json:"mode"` + Before undoState `json:"before"` + After undoState `json:"after"` +} + +// undoLog is the history, newest entry first. +type undoLog struct { + Entries []undoEntry `json:"entries"` + // Cleared is set once a submit or merge emptied the history, so an + // empty one can say why. + Cleared bool `json:"cleared,omitempty"` +} + +// undoLogPath is /git-stack/undo.json; branches are shared +// between worktrees, so the history is too. +func undoLogPath(repo git.Repo) string { + return filepath.Join(repo.CommonDir, "git-stack", "undo.json") +} + +// undoLockWait is how long a change to the history waits for its lock. +var undoLockWait = 5 * time.Second + +// loadUndoLog reads the history; a missing file is an empty one. +func loadUndoLog(repo git.Repo) (*undoLog, error) { + data, err := os.ReadFile(undoLogPath(repo)) + if errors.Is(err, fs.ErrNotExist) { + return &undoLog{}, nil + } + if err != nil { + return nil, err + } + var l undoLog + if err := json.Unmarshal(data, &l); err != nil { + return nil, fmt.Errorf("%s: %w", undoLogPath(repo), err) + } + return &l, nil +} + +// saveUndoLog writes the history atomically. +func saveUndoLog(repo git.Repo, l *undoLog) error { + p := undoLogPath(repo) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + data, err := json.Marshal(l) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(p), "undo.json.*") + if err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + return os.Rename(tmp.Name(), p) +} + +// editUndoLog changes the history under its lock: fn edits it, then it is +// trimmed to undoLimit, saved, and the keep alive ref rewritten to match. +func (a *App) editUndoLog(ctx context.Context, repo git.Repo, fn func(*undoLog) error) error { + p := undoLogPath(repo) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + unlock, err := lockFile(ctx, filepath.Join(filepath.Dir(p), "undo.lock"), undoLockWait) + if err != nil { + return err + } + defer unlock() + l, err := loadUndoLog(repo) + if err != nil { + return err + } + if err := fn(l); err != nil { + return err + } + if len(l.Entries) > undoLimit { + l.Entries = l.Entries[:undoLimit] + } + if err := saveUndoLog(repo, l); err != nil { + return err + } + return a.writeKeepAlive(ctx, repo, l) +} + +// writeKeepAlive points undoRef at a commit whose parents are every distinct +// commit the history's before states mention, or deletes it when there are +// none. +func (a *App) writeKeepAlive(ctx context.Context, repo git.Repo, l *undoLog) error { + var commits []string + for _, e := range l.Entries { + for _, sha := range e.Before.Refs { + if sha != "" { + commits = append(commits, sha) + } + } + for _, w := range e.Before.Worktrees { + if w.Head != "" { + commits = append(commits, w.Head) + } + } + } + slices.Sort(commits) + commits = slices.Compact(commits) + cur, exists, err := a.d.Git.Tip(ctx, repo, undoRef) + if err != nil { + return err + } + if len(commits) == 0 { + if !exists { + return nil + } + return a.d.Git.UpdateRefs(ctx, repo, []git.RefUpdate{{Ref: undoRef, Old: cur}}) + } + sha, err := a.d.Git.EmptyTreeCommit(ctx, repo, commits, "git-stack undo history\n\nKeeps the commits git stack undo may put back.") + if err != nil { + return err + } + return a.d.Git.UpdateRefs(ctx, repo, []git.RefUpdate{{Ref: undoRef, New: sha}}) +} diff --git a/pkg/app/undo_log_test.go b/pkg/app/undo_log_test.go new file mode 100644 index 0000000..ad66d70 --- /dev/null +++ b/pkg/app/undo_log_test.go @@ -0,0 +1,251 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// commitOn adds a commit to branch without checking it out. +func commitOn(t *testing.T, dir, branch, msg string) string { + t.Helper() + parent := gittest.Run(t, dir, "rev-parse", branch) + tree := gittest.Run(t, dir, "rev-parse", branch+"^{tree}") + sha := gittest.Run(t, dir, "commit-tree", tree, "-p", parent, "-m", msg) + gittest.Run(t, dir, "update-ref", "refs/heads/"+branch, sha) + return sha +} + +func TestUndoableRecordsNewestFirst(t *testing.T) { + a, repo, dir := stateRepo(t) + ctx := context.Background() + gittest.Run(t, dir, "switch", "-q", "main") + for i, cmd := range []string{"git stack one", "git stack two"} { + mode := []undoMode{undoKeep, undoSoft}[i] + err := a.undoable(ctx, repo, cmd, mode, func(context.Context) error { + commitOn(t, dir, "b", cmd) + return nil + }) + if err != nil { + t.Fatal(err) + } + } + l, err := loadUndoLog(repo) + if err != nil { + t.Fatal(err) + } + if len(l.Entries) != 2 || l.Entries[0].Command != "git stack two" || l.Entries[1].Command != "git stack one" { + t.Fatalf("entries = %+v", l.Entries) + } + e := l.Entries[0] + if e.Mode != undoSoft || e.Worktree != repo.TopLevel || e.At.IsZero() { + t.Errorf("entry = %+v", e) + } + if e.Before.Refs["b"] != l.Entries[1].After.Refs["b"] || e.After.Refs["b"] != gittest.Run(t, dir, "rev-parse", "b") { + t.Errorf("refs before %v after %v", e.Before.Refs, e.After.Refs) + } + if _, ok := e.Before.Refs["main"]; !ok { + t.Error("the trunk should be recorded") + } + if len(e.Before.Worktrees) != 1 || e.Before.Worktrees[0].Branch != "main" { + t.Errorf("worktrees = %+v", e.Before.Worktrees) + } +} + +func TestUndoableRecordsNothingForANoop(t *testing.T) { + a, repo, _ := stateRepo(t) + ctx := context.Background() + if err := a.undoable(ctx, repo, "git stack restack", undoKeep, func(context.Context) error { return nil }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(undoLogPath(repo)); !errors.Is(err, os.ErrNotExist) { + l, _ := loadUndoLog(repo) + if len(l.Entries) != 0 { + t.Errorf("a no-op recorded %+v", l.Entries) + } + } +} + +func TestUndoableInsideWithoutUndoRecordsNothing(t *testing.T) { + a, repo, dir := stateRepo(t) + ctx := withoutUndo(context.Background()) + if err := a.undoable(ctx, repo, "git stack sync", undoKeep, func(context.Context) error { + commitOn(t, dir, "b", "x") + return nil + }); err != nil { + t.Fatal(err) + } + if l, _ := loadUndoLog(repo); len(l.Entries) != 0 { + t.Errorf("recorded %+v", l.Entries) + } +} + +func TestUndoableRecordsNothingOnError(t *testing.T) { + a, repo, dir := stateRepo(t) + ctx := context.Background() + failed := errors.New("boom") + err := a.undoable(ctx, repo, "git stack restack", undoKeep, func(context.Context) error { + commitOn(t, dir, "b", "x") + return failed + }) + if !errors.Is(err, failed) { + t.Fatalf("err = %v", err) + } + if l, _ := loadUndoLog(repo); len(l.Entries) != 0 { + t.Errorf("recorded %+v", l.Entries) + } + err = a.undoable(ctx, repo, "git stack sync", undoKeep, func(context.Context) error { + commitOn(t, dir, "b", "y") + return stack.New(stack.KindPartial, "some of it") + }) + if se, ok := errors.AsType[*stack.Error](err); !ok || se.Kind != stack.KindPartial { + t.Fatalf("err = %v", err) + } + if l, _ := loadUndoLog(repo); len(l.Entries) != 1 { + t.Errorf("a partial result should be recorded: %+v", l.Entries) + } +} + +// keepAliveParents lists the parents of the keep alive ref's commit, or nil +// when the ref doesn't exist. +func keepAliveParents(t *testing.T, dir string) []string { + t.Helper() + out, err := gittest.RunErr(t, dir, "rev-list", "--parents", "-n1", undoRef) + if err != nil { + return nil + } + parents := strings.Fields(out)[1:] + slices.Sort(parents) + return parents +} + +func TestUndoHistoryKeepsTwentyAndTheirCommits(t *testing.T) { + a, repo, dir := stateRepo(t) + ctx := context.Background() + gittest.Run(t, dir, "switch", "-q", "main") + first := gittest.Run(t, dir, "rev-parse", "b") + for i := range undoLimit + 1 { + err := a.undoable(ctx, repo, fmt.Sprintf("git stack %d", i), undoKeep, func(context.Context) error { + commitOn(t, dir, "b", fmt.Sprint(i)) + return nil + }) + if err != nil { + t.Fatal(err) + } + } + l, err := loadUndoLog(repo) + if err != nil { + t.Fatal(err) + } + if len(l.Entries) != undoLimit || l.Entries[undoLimit-1].Command != "git stack 1" { + t.Fatalf("kept %d entries, oldest %q", len(l.Entries), l.Entries[len(l.Entries)-1].Command) + } + var want []string + for _, e := range l.Entries { + for _, sha := range e.Before.Refs { + want = append(want, sha) + } + for _, w := range e.Before.Worktrees { + want = append(want, w.Head) + } + } + slices.Sort(want) + want = slices.Compact(want) + got := keepAliveParents(t, dir) + if !slices.Equal(got, want) { + t.Errorf("parents = %v, want %v", got, want) + } + if slices.Contains(got, first) { + t.Error("the dropped entry's commit should no longer be kept alive") + } +} + +func TestClearUndo(t *testing.T) { + a, repo, dir := stateRepo(t) + ctx := context.Background() + gittest.Run(t, dir, "switch", "-q", "main") + if err := a.undoable(ctx, repo, "git stack one", undoKeep, func(context.Context) error { + commitOn(t, dir, "b", "x") + return nil + }); err != nil { + t.Fatal(err) + } + if keepAliveParents(t, dir) == nil { + t.Fatal("the ref should exist") + } + if err := a.clearUndo(ctx, repo); err != nil { + t.Fatal(err) + } + l, err := loadUndoLog(repo) + if err != nil || len(l.Entries) != 0 || !l.Cleared { + t.Errorf("log = %+v %v", l, err) + } + if keepAliveParents(t, dir) != nil { + t.Error("the ref should be gone") + } +} + +// conflictRepo is stateRepo with main changing shared.txt the other way, so +// restacking b stops on a conflict. +func conflictRepo(t *testing.T) (*App, string) { + t.Helper() + a, _, dir := stateRepo(t) + gittest.Commit(t, dir, "shared.txt", "b side", "b: shared") + gittest.Run(t, dir, "switch", "-q", "main") + gittest.Commit(t, dir, "shared.txt", "main side", "main: shared") + gittest.Run(t, dir, "switch", "-q", "b") + return a, dir +} + +func TestStoppedRestackCarriesTheUndo(t *testing.T) { + for _, then := range []string{"continue", "abort"} { + t.Run(then, func(t *testing.T) { + a, dir := conflictRepo(t) + ctx := context.Background() + repo, err := a.d.Git.Discover(ctx, dir) + if err != nil { + t.Fatal(err) + } + _, err = a.Restack(ctx, repo, RestackOptions{}) + if se, ok := errors.AsType[*stack.Error](err); !ok || se.Kind != stack.KindConflict { + t.Fatalf("restack = %v", err) + } + st, err := loadRestackState(repo) + if err != nil || st == nil || st.Undo == nil || st.Undo.Command != "git stack restack" { + t.Fatalf("state = %+v %v", st, err) + } + if l, _ := loadUndoLog(repo); len(l.Entries) != 0 { + t.Fatalf("a stopped restack recorded %+v", l.Entries) + } + if then == "continue" { + gittest.WriteFile(t, dir, "shared.txt", "resolved") + gittest.Run(t, dir, "add", "shared.txt") + t.Setenv("GIT_EDITOR", "true") + _, err = a.Restack(ctx, repo, RestackOptions{Continue: true}) + } else { + _, err = a.Restack(ctx, repo, RestackOptions{Abort: true}) + } + if err != nil { + t.Fatal(err) + } + l, _ := loadUndoLog(repo) + want := 0 + if then == "continue" { + want = 1 + } + if len(l.Entries) != want { + t.Fatalf("entries = %+v", l.Entries) + } + if want == 1 && (l.Entries[0].Command != "git stack restack" || l.Entries[0].Mode != undoKeep) { + t.Errorf("entry = %+v", l.Entries[0]) + } + }) + } +} diff --git a/pkg/app/undo_record.go b/pkg/app/undo_record.go new file mode 100644 index 0000000..cc29754 --- /dev/null +++ b/pkg/app/undo_record.go @@ -0,0 +1,175 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io/fs" + "maps" + "os" + "slices" + "time" + + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// undoSnapshot is a covered command's before state while it runs. A +// restack that stops on a conflict saves it with its state, so continue can +// record the whole command once it finishes. +type undoSnapshot struct { + Command string `json:"command"` + Mode undoMode `json:"mode"` + Worktree string `json:"worktree"` + At time.Time `json:"at"` + // Before.Refs holds every local branch; recording keeps only the ones + // in Tracked or in the graph afterwards. + Before undoState `json:"before"` + // Tracked are the trunks and stacked branches the graph had before. + Tracked []string `json:"tracked"` +} + +type undoCtxKey struct{} + +// undoMark is what undoable leaves on the context: the snapshot of the +// command running, or none to say that nothing inside records. +type undoMark struct{ snap *undoSnapshot } + +// undoFrom is the snapshot of the covered command ctx runs inside, if any. +func undoFrom(ctx context.Context) *undoSnapshot { + if m, ok := ctx.Value(undoCtxKey{}).(undoMark); ok { + return m.snap + } + return nil +} + +// withoutUndo marks ctx so covered commands run inside it record nothing +// (merge's sync, which happens after GitHub has already changed). +func withoutUndo(ctx context.Context) context.Context { + return context.WithValue(ctx, undoCtxKey{}, undoMark{}) +} + +// undoable runs a covered command's body: it takes the before snapshot, +// runs fn and, when anything changed, records an entry. A run that stopped +// with its restack state saved records nothing yet (continue does); one that +// failed records nothing either, except a partial result, whose done part +// can be undone. A command run inside another one's undoable (or inside +// withoutUndo) just runs. +func (a *App) undoable(ctx context.Context, repo git.Repo, command string, mode undoMode, fn func(context.Context) error) error { + if _, ok := ctx.Value(undoCtxKey{}).(undoMark); ok { + return fn(ctx) + } + before, tracked, err := a.captureUndo(ctx, repo) + if err != nil { + return err + } + snap := &undoSnapshot{ + Command: command, Mode: mode, Worktree: repo.TopLevel, + At: time.Now(), Before: before, Tracked: tracked, + } + err = fn(context.WithValue(ctx, undoCtxKey{}, undoMark{snap})) + if err != nil { + if st, lerr := loadRestackState(repo); lerr != nil || st != nil { + return err + } + if se, ok := errors.AsType[*stack.Error](err); !ok || se.Kind != stack.KindPartial { + return err + } + } + if rerr := a.recordUndo(ctx, repo, *snap); rerr != nil { + // The command itself worked; losing its undo isn't worth failing it. + a.d.Log.Warn("could not record the command for undo", "command", command, "err", rerr) + } + return err +} + +// undoableResult is undoable for a use case that returns a result. +func undoableResult[T any](a *App, ctx context.Context, repo git.Repo, command string, mode undoMode, fn func(context.Context) (T, error)) (T, error) { + var res T + err := a.undoable(ctx, repo, command, mode, func(ctx context.Context) error { + var err error + res, err = fn(ctx) + return err + }) + return res, err +} + +// captureUndo reads the state undo can put back: every local branch's tip, +// the metadata, the remote queue and each worktree's HEAD. tracked are the +// graph's trunks and branches. +func (a *App) captureUndo(ctx context.Context, repo git.Repo) (undoState, []string, error) { + graph, err := a.d.Meta.Load(ctx, repo) + if err != nil { + return undoState{}, nil, err + } + local, err := a.localBranches(ctx, repo) + if err != nil { + return undoState{}, nil, err + } + st := undoState{Refs: make(map[string]string, len(local))} + for name, b := range local { + st.Refs[name] = b.Head + } + if st.Meta, err = a.d.Meta.Snapshot(ctx, repo); err != nil { + return undoState{}, nil, err + } + if st.Queue, err = os.ReadFile(remoteStacksPath(repo)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return undoState{}, nil, err + } + wts, err := a.d.Git.Worktrees(ctx, repo) + if err != nil { + return undoState{}, nil, err + } + for _, w := range wts { + if w.Bare || w.Prunable { + continue + } + st.Worktrees = append(st.Worktrees, undoWorktree{Path: w.Path, Branch: w.Branch, Head: w.Head}) + } + return st, graphNames(graph), nil +} + +// graphNames are the trunks and stacked branches of g. +func graphNames(g *stack.Graph) []string { + names := slices.Clone(g.Trunks) + for _, s := range g.Stacks { + names = append(names, s.Names()...) + } + return names +} + +// recordUndo reads the after state and adds the entry, keeping only the +// branches the graph tracked before or after. A command that changed +// nothing records nothing. +func (a *App) recordUndo(ctx context.Context, repo git.Repo, snap undoSnapshot) error { + after, tracked, err := a.captureUndo(ctx, repo) + if err != nil { + return err + } + names := append(slices.Clone(snap.Tracked), tracked...) + before := snap.Before + beforeRefs, afterRefs := map[string]string{}, map[string]string{} + for _, n := range names { + beforeRefs[n] = before.Refs[n] + afterRefs[n] = after.Refs[n] + } + before.Refs, after.Refs = beforeRefs, afterRefs + if maps.Equal(before.Refs, after.Refs) && bytes.Equal(before.Queue, after.Queue) && + maps.EqualFunc(before.Meta.Files, after.Meta.Files, bytes.Equal) { + return nil + } + e := undoEntry{Command: snap.Command, At: snap.At, Worktree: snap.Worktree, Mode: snap.Mode, Before: before, After: after} + return a.editUndoLog(ctx, repo, func(l *undoLog) error { + l.Entries = slices.Insert(l.Entries, 0, e) + return nil + }) +} + +// clearUndo empties the history once GitHub has a state undo can't take +// back (a submit or merge). +func (a *App) clearUndo(ctx context.Context, repo git.Repo) error { + return a.editUndoLog(ctx, repo, func(l *undoLog) error { + l.Entries, l.Cleared = nil, true + return nil + }) +} diff --git a/pkg/app/undo_reshape_test.go b/pkg/app/undo_reshape_test.go new file mode 100644 index 0000000..ccc44bb --- /dev/null +++ b/pkg/app/undo_reshape_test.go @@ -0,0 +1,123 @@ +package app_test + +import ( + "context" + "slices" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +// Pop leaves the branch's changes staged on its parent; undo brings the +// branch back and its checkout with it, with nothing left staged. +func TestUndoPop(t *testing.T) { + f := newRestackFixture(t) // main -> a -> b -> c + gittest.Run(t, f.dir, "switch", "-q", "b") + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Pop(ctx, f.repo); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack pop (soft)") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if cur := current(t, f.dir); cur != "b" { + t.Errorf("current = %s", cur) + } + if st := status(t, f.dir); st != "" { + t.Errorf("status = %q", st) + } + if !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Errorf("stack = %v", f.names()) + } +} + +func TestUndoFold(t *testing.T) { + f := newRestackFixture(t) // HEAD on c + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Fold(ctx, f.repo, app.FoldOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack fold (keep)") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if cur := current(t, f.dir); cur != "c" { + t.Errorf("current = %s", cur) + } + if !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Errorf("stack = %v", f.names()) + } +} + +func TestUndoUntrackOnly(t *testing.T) { + f := newRestackFixture(t) + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Untrack(ctx, f.repo, app.UntrackOptions{Branch: "b", Only: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack untrack --only (keep)") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if !slices.Equal(f.names(), []string{"a", "b", "c"}) || f.base("c") != before["b"] { + t.Errorf("stack = %v, base of c %s", f.names(), f.base("c")) + } +} + +// Create --insert runs inside create, so undo gives the committed changes +// back staged on the branch it started from, and the branch above returns. +func TestUndoCreateInsert(t *testing.T) { + f := newRestackFixture(t) + gittest.Run(t, f.dir, "switch", "-q", "a") + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + gittest.WriteFile(t, f.dir, "n.txt", "n") + gittest.Run(t, f.dir, "add", "n.txt") + if _, err := f.app.Create(ctx, f.repo, app.CreateOptions{Name: "n", Message: []string{"Add n"}, Insert: true}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if cur := current(t, f.dir); cur != "a" { + t.Errorf("current = %s", cur) + } + if got := gittest.Run(t, f.dir, "diff", "--cached", "--name-only"); got != "n.txt" { + t.Errorf("staged = %q", got) + } + if !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Errorf("stack = %v", f.names()) + } +} + +func TestUndoLocalRename(t *testing.T) { + f := newRestackFixture(t) // HEAD on c + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Rename(ctx, f.repo, app.RenameOptions{Name: "d", Local: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack rename --local (keep)") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if cur := current(t, f.dir); cur != "c" { + t.Errorf("current = %s", cur) + } + if _, err := gittest.RunErr(t, f.dir, "rev-parse", "--verify", "-q", "refs/heads/d"); err == nil { + t.Error("d should be gone") + } + if !slices.Equal(f.names(), []string{"a", "b", "c"}) { + t.Errorf("stack = %v", f.names()) + } +} diff --git a/pkg/app/undo_test.go b/pkg/app/undo_test.go new file mode 100644 index 0000000..ef49d54 --- /dev/null +++ b/pkg/app/undo_test.go @@ -0,0 +1,416 @@ +package app_test + +import ( + "context" + "errors" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/exec" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +// tips reads the named branches' tips. +func tips(t *testing.T, dir string, names ...string) map[string]string { + t.Helper() + out := map[string]string{} + for _, n := range names { + out[n] = gittest.Run(t, dir, "rev-parse", n) + } + return out +} + +func wantTips(t *testing.T, dir string, want map[string]string) { + t.Helper() + for n, sha := range want { + if got := gittest.Run(t, dir, "rev-parse", n); got != sha { + t.Errorf("%s = %s, want %s", n, got, sha) + } + } +} + +func current(t *testing.T, dir string) string { + t.Helper() + return gittest.Run(t, dir, "branch", "--show-current") +} + +func status(t *testing.T, dir string) string { + t.Helper() + return gittest.Run(t, dir, "status", "--short", "--untracked-files=no") +} + +func undoKind(err error) stack.Kind { + if se, ok := errors.AsType[*stack.Error](err); ok { + return se.Kind + } + return stack.KindUnknown +} + +func TestUndoCreate(t *testing.T) { + a, fb, repo, dir, _ := mutFixture(t) + ctx := context.Background() + gittest.WriteFile(t, dir, "b.txt", "b") + gittest.Run(t, dir, "add", "b.txt") + before := tips(t, dir, "main", "a") + created, err := a.Create(ctx, repo, app.CreateOptions{Message: []string{"Add B"}}) + if err != nil { + t.Fatal(err) + } + res, err := a.Undo(ctx, repo, app.UndoOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Undone == nil || res.Undone.Command != "git stack create" || !res.Staged { + t.Errorf("result = %+v", res) + } + if !slices.Contains(res.Undone.Restores, created.Branch+" deleted") { + t.Errorf("restores = %q", res.Undone.Restores) + } + if current(t, dir) != "a" || status(t, dir) != "A b.txt" { + t.Errorf("on %s with %q", current(t, dir), status(t, dir)) + } + wantTips(t, dir, before) + if branchExists(t, dir, created.Branch) { + t.Error("the created branch should be gone") + } + if len(fb.graph.Stacks) != 1 || !slices.Equal(fb.graph.Stacks[0].Names(), []string{"a"}) { + t.Errorf("stacks = %+v", fb.graph.Stacks) + } + if _, err := a.Undo(ctx, repo, app.UndoOptions{}); undoKind(err) != stack.KindInvalidArgs { + t.Errorf("a second undo = %v", err) + } +} + +func TestUndoModify(t *testing.T) { + a, _, repo, dir, _ := mutFixture(t) + ctx := context.Background() + before := tips(t, dir, "a") + gittest.WriteFile(t, dir, "a.txt", "a2") + gittest.Run(t, dir, "add", "a.txt") + if _, err := a.Modify(ctx, repo, app.ModifyOptions{}); err != nil { + t.Fatal(err) + } + res, err := a.Undo(ctx, repo, app.UndoOptions{}) + if err != nil || !res.Staged { + t.Fatalf("undo = %+v %v", res, err) + } + wantTips(t, dir, before) + if status(t, dir) != "M a.txt" { + t.Errorf("status = %q", status(t, dir)) + } +} + +func TestUndoAbsorb(t *testing.T) { + f := newAbsorbFixture(t) + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + f.stageLine(t, 5, "FIVE") + if _, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if current(t, f.dir) != "b" || status(t, f.dir) != "M f.txt" { + t.Errorf("on %s with %q", current(t, f.dir), status(t, f.dir)) + } +} + +func TestUndoRestack(t *testing.T) { + f := newRestackFixture(t) + ctx := context.Background() + advanceMain(t, f.dir) + before := tips(t, f.dir, "main", "a", "b", "c") + bases := []string{f.base("a"), f.base("b"), f.base("c")} + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + res, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}) + if err != nil || res.Staged { + t.Fatalf("undo = %+v %v", res, err) + } + wantTips(t, f.dir, before) + if got := []string{f.base("a"), f.base("b"), f.base("c")}; !slices.Equal(got, bases) { + t.Errorf("bases = %v, want %v", got, bases) + } + if current(t, f.dir) != "c" || status(t, f.dir) != "" { + t.Errorf("on %s with %q", current(t, f.dir), status(t, f.dir)) + } +} + +func TestUndoReorder(t *testing.T) { + f := newRestackFixture(t) + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Reorder(ctx, f.repo, app.ReorderOptions{Order: []string{"b", "a", "c"}}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if !slices.Equal(f.names(), []string{"a", "b", "c"}) || f.base("b") != before["a"] { + t.Errorf("stack = %v, base of b %s", f.names(), f.base("b")) + } +} + +func TestUndoMove(t *testing.T) { + f := newRestackFixture(t) + addStack(t, f, false) + ctx := context.Background() + before := tips(t, f.dir, "a", "b", "c", "x") + if _, err := f.app.Move(ctx, f.repo, app.MoveOptions{Source: "b", Onto: "x"}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + want := [][]string{{"a", "b", "c"}, {"x"}} + if got := stackNames(f); !slices.EqualFunc(got, want, slices.Equal) { + t.Errorf("stacks = %v", got) + } +} + +func TestUndoInit(t *testing.T) { + f := newRestackFixture(t) + ctx := context.Background() + gittest.Run(t, f.dir, "switch", "-q", "-c", "p", "main") + gittest.Commit(t, f.dir, "p.txt", "p", "p") + if _, err := f.app.Init(ctx, f.repo, app.InitOptions{Branches: []string{"p"}}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + if f.fb.graph.Tracked("p") || !branchExists(t, f.dir, "p") { + t.Error("p should exist but not be tracked") + } +} + +func TestUndoSyncBringsBackADeletedBranch(t *testing.T) { + f := newSyncFixture(t) // on b + ctx := context.Background() + f.mergeOnRemote(t, "a", 0) + before := tips(t, f.dir, "main", "a", "b") + if _, err := f.sync(t, app.SyncOptions{}); err != nil { + t.Fatal(err) + } + if branchExists(t, f.dir, "a") { + t.Fatal("sync should have deleted a") + } + res, err := app.New(f.deps).Undo(ctx, f.repo, app.UndoOptions{}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(res.Undone.Restores, "a recreated") { + t.Errorf("restores = %q", res.Undone.Restores) + } + wantTips(t, f.dir, before) + g, _ := f.deps.Meta.Load(ctx, f.repo) + if len(g.Stacks) != 1 || !slices.Equal(g.Stacks[0].Names(), []string{"a", "b"}) { + t.Errorf("stacks = %+v", g.Stacks) + } + if current(t, f.dir) != "b" || status(t, f.dir) != "" { + t.Errorf("on %s with %q", current(t, f.dir), status(t, f.dir)) + } +} + +func TestUndoStepsBack(t *testing.T) { + a, _, repo, dir, _ := mutFixture(t) + ctx := context.Background() + for _, n := range []string{"x", "y"} { + gittest.WriteFile(t, dir, n+".txt", n) + gittest.Run(t, dir, "add", n+".txt") + if _, err := a.Create(ctx, repo, app.CreateOptions{Message: []string{"Add " + n}}); err != nil { + t.Fatal(err) + } + } + list, err := a.Undo(ctx, repo, app.UndoOptions{List: true}) + if err != nil || len(list.Entries) != 2 || list.Entries[0].Command != "git stack create" || list.Undone != nil { + t.Fatalf("list = %+v %v", list, err) + } + if !list.Entries[0].At.After(list.Entries[1].At) { + t.Error("the list should be newest first") + } + if len(app.UndoHistory(repo)) != 2 { + t.Error("listing must not change anything") + } + for range 2 { + if _, err := a.Undo(ctx, repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + } + if current(t, dir) != "a" || status(t, dir) != "A x.txt\nA y.txt" { + t.Errorf("on %s with %q", current(t, dir), status(t, dir)) + } + _, err = a.Undo(ctx, repo, app.UndoOptions{}) + if undoKind(err) != stack.KindInvalidArgs || !strings.Contains(err.Error(), "nothing to undo") { + t.Errorf("third undo = %v", err) + } +} + +func TestUndoIsPerWorktree(t *testing.T) { + f := newRestackFixture(t) // on c + ctx := context.Background() + wt := filepath.Join(t.TempDir(), "wt") + gittest.Run(t, f.dir, "worktree", "add", "-q", "-b", "p", wt, "main") + gittest.Commit(t, wt, "p.txt", "p", "p") + g := git.New(exec.New()) + other, err := g.Discover(ctx, wt) + if err != nil { + t.Fatal(err) + } + b := app.New(app.Deps{Git: g, Meta: f.fb, Tracker: f.fb}) + if _, err := b.Init(ctx, other, app.InitOptions{Branches: []string{"p"}}); err != nil { + t.Fatal(err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); undoKind(err) != stack.KindInvalidArgs { + t.Fatalf("undo here = %v", err) + } + if list, err := f.app.Undo(ctx, f.repo, app.UndoOptions{List: true}); err != nil || len(list.Entries) != 0 { + t.Fatalf("list here = %+v %v", list, err) + } + advanceMain(t, f.dir) + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + res, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}) + if err != nil || res.Undone.Command != "git stack restack" { + t.Fatalf("undo = %+v %v", res, err) + } + if !f.fb.graph.Tracked("p") { + t.Error("the other worktree's init should still be there") + } + // main moved on by hand since the init, so it takes --force. + if res, err := b.Undo(ctx, other, app.UndoOptions{Force: true}); err != nil || res.Undone.Command != "git stack init" { + t.Errorf("undo there = %+v %v", res, err) + } + if f.fb.graph.Tracked("p") { + t.Error("undoing the init should stop tracking p") + } +} + +func TestUndoRefusesDriftUnlessForced(t *testing.T) { + f := newRestackFixture(t) // on c + ctx := context.Background() + advanceMain(t, f.dir) + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + gittest.Commit(t, f.dir, "later.txt", "later", "later") + _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}) + if undoKind(err) != stack.KindInvalidArgs || !strings.Contains(err.Error(), "c") { + t.Fatalf("undo = %v", err) + } + if se, _ := errors.AsType[*stack.Error](err); !strings.Contains(strings.Join(se.NextSteps, "\n"), "git stack undo --force") { + t.Errorf("steps = %v", se.NextSteps) + } + if len(app.UndoHistory(f.repo)) != 1 { + t.Error("a refused undo must keep the entry") + } + gittest.WriteFile(t, f.dir, "a.txt", "mine") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{Force: true}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if status(t, f.dir) != "M a.txt" { + t.Errorf("the working tree change should survive: %q", status(t, f.dir)) + } +} + +func TestUndoRefusesWhenChangesAreInTheWay(t *testing.T) { + f := newRestackFixture(t) // on c + ctx := context.Background() + advanceMain(t, f.dir) // adds m.txt + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + after := tips(t, f.dir, "a", "b", "c") + gittest.WriteFile(t, f.dir, "m.txt", "mine") + _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}) + if undoKind(err) != stack.KindInvalidArgs || !strings.Contains(err.Error(), "m.txt") { + t.Fatalf("undo = %v", err) + } + wantTips(t, f.dir, after) + if len(app.UndoHistory(f.repo)) != 1 { + t.Error("a refused undo must keep the entry") + } +} + +func TestUndoKeepsUnrelatedChanges(t *testing.T) { + f := newRestackFixture(t) // on c + ctx := context.Background() + advanceMain(t, f.dir) + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + gittest.WriteFile(t, f.dir, "a.txt", "mine") + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if status(t, f.dir) != "M a.txt" { + t.Errorf("status = %q", status(t, f.dir)) + } +} + +func TestUndoMovesOtherWorktrees(t *testing.T) { + for _, dirty := range []bool{false, true} { + t.Run(map[bool]string{false: "clean", true: "dirty"}[dirty], func(t *testing.T) { + f := newRestackFixture(t) // on c + ctx := context.Background() + wt := filepath.Join(t.TempDir(), "wt") + gittest.Run(t, f.dir, "worktree", "add", "-q", wt, "b") + advanceMain(t, f.dir) // adds m.txt + before := tips(t, f.dir, "a", "b", "c") + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + after := tips(t, f.dir, "a", "b", "c") + if after["b"] == before["b"] { + t.Fatal("restack should have moved b") + } + if dirty { + gittest.WriteFile(t, wt, "m.txt", "mine") + } + _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}) + if dirty { + if undoKind(err) != stack.KindInvalidArgs || !strings.Contains(err.Error(), "m.txt") { + t.Fatalf("undo = %v", err) + } + wantTips(t, f.dir, after) + return + } + if err != nil { + t.Fatal(err) + } + wantTips(t, f.dir, before) + if gittest.Run(t, wt, "rev-parse", "HEAD") != before["b"] || status(t, wt) != "" { + t.Errorf("the other worktree should follow b back: %q", status(t, wt)) + } + }) + } +} + +func TestUndoRefusesDuringARestack(t *testing.T) { + f := newRestackFixture(t) + ctx := context.Background() + gittest.Run(t, f.dir, "switch", "-q", "main") + gittest.Commit(t, f.dir, "a.txt", "main's a", "main: a") + gittest.Run(t, f.dir, "switch", "-q", "c") + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); undoKind(err) != stack.KindConflict { + t.Fatalf("restack = %v", err) + } + if _, err := f.app.Undo(ctx, f.repo, app.UndoOptions{}); undoKind(err) != stack.KindRebaseActive { + t.Errorf("undo = %v", err) + } +} diff --git a/pkg/app/undo_wire_test.go b/pkg/app/undo_wire_test.go new file mode 100644 index 0000000..0860141 --- /dev/null +++ b/pkg/app/undo_wire_test.go @@ -0,0 +1,149 @@ +package app_test + +import ( + "context" + "slices" + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +func wantHistory(t *testing.T, repo git.Repo, want ...string) { + t.Helper() + if got := app.UndoHistory(repo); !slices.Equal(got, want) { + t.Errorf("undo history = %q, want %q", got, want) + } +} + +// advanceMain commits on main without leaving the current branch, so the +// stack above it needs a restack. +func advanceMain(t *testing.T, dir string) { + t.Helper() + cur := gittest.Run(t, dir, "branch", "--show-current") + gittest.Run(t, dir, "switch", "-q", "main") + gittest.Commit(t, dir, "m.txt", "m", "main moves on") + gittest.Run(t, dir, "switch", "-q", cur) +} + +func TestCoveredCommandsRecordForUndo(t *testing.T) { + ctx := context.Background() + t.Run("create", func(t *testing.T) { + a, _, repo, dir, _ := mutFixture(t) + gittest.WriteFile(t, dir, "b.txt", "b") + gittest.Run(t, dir, "add", "b.txt") + if _, err := a.Create(ctx, repo, app.CreateOptions{Message: []string{"Add B"}}); err != nil { + t.Fatal(err) + } + wantHistory(t, repo, "git stack create (soft)") + }) + t.Run("modify", func(t *testing.T) { + a, _, repo, dir, _ := mutFixture(t) + gittest.WriteFile(t, dir, "a.txt", "a2") + gittest.Run(t, dir, "add", "a.txt") + if _, err := a.Modify(ctx, repo, app.ModifyOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, repo, "git stack modify (soft)") + }) + t.Run("absorb", func(t *testing.T) { + f := newAbsorbFixture(t) + f.stageLine(t, 5, "FIVE") + if _, err := f.app.Absorb(ctx, f.repo, app.AbsorbOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack absorb (soft)") + }) + t.Run("restack", func(t *testing.T) { + f := newRestackFixture(t) + advanceMain(t, f.dir) + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack restack (keep)") + }) + t.Run("reorder", func(t *testing.T) { + f := newRestackFixture(t) + if _, err := f.app.Reorder(ctx, f.repo, app.ReorderOptions{Order: []string{"b", "a", "c"}}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack reorder (keep)") + }) + t.Run("move", func(t *testing.T) { + f := newRestackFixture(t) + addStack(t, f, false) + if _, err := f.app.Move(ctx, f.repo, app.MoveOptions{Source: "b", Onto: "x"}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack move (keep)") + }) + t.Run("init", func(t *testing.T) { + f := newRestackFixture(t) + gittest.Run(t, f.dir, "switch", "-q", "-c", "p", "main") + gittest.Commit(t, f.dir, "p.txt", "p", "p") + if _, err := f.app.Init(ctx, f.repo, app.InitOptions{Branches: []string{"p"}}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack init (keep)") + }) + t.Run("sync", func(t *testing.T) { + f := newSyncFixture(t) + f.advanceRemote(t, "main", "r.txt") + if _, err := f.sync(t, app.SyncOptions{NoRestack: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack sync (keep)") + }) + t.Run("nothing to do", func(t *testing.T) { + f := newRestackFixture(t) + if _, err := f.app.Restack(ctx, f.repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo) + }) +} + +func keepAliveExists(t *testing.T, dir string) bool { + t.Helper() + _, err := gittest.RunErr(t, dir, "rev-parse", "--verify", "--quiet", "refs/git-stack/undo") + return err == nil +} + +func TestSubmitClearsTheUndoHistory(t *testing.T) { + deps, _, _, repo, dir := submitFixture(t) + a := app.New(deps) + ctx := context.Background() + advanceMain(t, dir) + if _, err := a.Restack(ctx, repo, app.RestackOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, repo, "git stack restack (keep)") + if _, err := a.Submit(ctx, repo, app.SubmitOptions{DryRun: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, repo, "git stack restack (keep)") + if _, err := a.Submit(ctx, repo, app.SubmitOptions{Publish: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, repo) + if keepAliveExists(t, dir) { + t.Error("the keep alive ref should be gone") + } +} + +func TestMergeClearsTheUndoHistory(t *testing.T) { + f := mergeFixture(t) + f.advanceRemote(t, "main", "r.txt") + if _, err := f.sync(t, app.SyncOptions{NoRestack: true}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo, "git stack sync (keep)") + if _, err := f.merge(t, app.MergeOptions{}); err != nil { + t.Fatal(err) + } + wantHistory(t, f.repo) + if keepAliveExists(t, f.dir) { + t.Error("the keep alive ref should be gone") + } +} diff --git a/pkg/app/untrack.go b/pkg/app/untrack.go index 87c8d1e..f9adedd 100644 --- a/pkg/app/untrack.go +++ b/pkg/app/untrack.go @@ -44,6 +44,17 @@ const untrackCommand = "git stack untrack" // A stack GitHub knows is queued to be dissolved at the next submit; no // pull request is closed. func (a *App) Untrack(ctx context.Context, repo git.Repo, o UntrackOptions) (UntrackResult, error) { + command := "git stack untrack" + if o.Only { + command += " --only" + } + return undoableResult(a, ctx, repo, command, undoKeep, func(ctx context.Context) (UntrackResult, error) { + return a.untrack(ctx, repo, o) + }) +} + +// untrack is Untrack's body. +func (a *App) untrack(ctx context.Context, repo git.Repo, o UntrackOptions) (UntrackResult, error) { if err := a.noRebaseActive(ctx, repo); err != nil { return UntrackResult{}, err } diff --git a/pkg/backend/ghstack/backend.go b/pkg/backend/ghstack/backend.go index 1e18875..340c613 100644 --- a/pkg/backend/ghstack/backend.go +++ b/pkg/backend/ghstack/backend.go @@ -420,7 +420,12 @@ func writeFile(gitDir string, root *node) error { if err != nil { return err } - path := filepath.Join(gitDir, FileName) + return writeBytes(filepath.Join(gitDir, FileName), out) +} + +// writeBytes replaces path with out atomically, keeping the mode of the +// file it replaces. +func writeBytes(path string, out []byte) error { tmp, err := os.CreateTemp(filepath.Dir(path), FileName+".*") if err != nil { return err @@ -543,3 +548,89 @@ func (b *Backend) ghRun(ctx context.Context, repo git.Repo, stream io.Writer, ar } return res, nil } + +// lockDirs takes gh stack's lock in each of dirs, in sorted order so two of +// us never deadlock. The returned function releases them all. +func lockDirs(ctx context.Context, dirs []string) (func(), error) { + dirs = slices.Clone(dirs) + slices.Sort(dirs) + dirs = slices.Compact(dirs) + var unlocks []func() + release := func() { + for _, u := range slices.Backward(unlocks) { + u() + } + } + for _, dir := range dirs { + unlock, err := lock(ctx, filepath.Join(dir, LockName), lockWait) + if err != nil { + release() + return nil, err + } + unlocks = append(unlocks, unlock) + } + return release, nil +} + +// Snapshot implements stack.Metadata. It reads every metadata file Load +// would (the current worktree's, the main checkout's and every linked +// worktree's) under the lock, recording the ones that don't exist as empty +// so Restore can remove them again. +func (b *Backend) Snapshot(ctx context.Context, repo git.Repo) (stack.Snapshot, error) { + var dirs []string + for _, src := range sources(repo) { + dirs = append(dirs, src.gitDir) + } + unlock, err := lockDirs(ctx, append(slices.Clone(dirs), repo.GitDir)) + if err != nil { + return stack.Snapshot{}, err + } + defer unlock() + snap := stack.Snapshot{Files: map[string][]byte{}} + for _, dir := range dirs { + path := filepath.Join(dir, FileName) + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return stack.Snapshot{}, err + } + snap.Files[path] = data + } + return snap, nil +} + +// Restore implements stack.Metadata. It writes each file in the snapshot +// back exactly, under the lock, and removes the ones that were empty (did not +// exist) when it was taken. A file whose git dir has gone since (a removed +// worktree) is skipped, and files the snapshot doesn't know about are left +// alone. +func (b *Backend) Restore(ctx context.Context, repo git.Repo, s stack.Snapshot) error { + var dirs []string + for path := range s.Files { + dir := filepath.Dir(path) + if _, err := os.Stat(dir); err == nil { + dirs = append(dirs, dir) + } + } + unlock, err := lockDirs(ctx, append(slices.Clone(dirs), repo.GitDir)) + if err != nil { + return err + } + defer unlock() + for _, dir := range dirs { + path := filepath.Join(dir, FileName) + data := s.Files[path] + if len(data) == 0 { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + continue + } + if cur, err := os.ReadFile(path); err == nil && string(cur) == string(data) { + continue + } + if err := writeBytes(path, data); err != nil { + return err + } + } + return nil +} diff --git a/pkg/backend/ghstack/backend_test.go b/pkg/backend/ghstack/backend_test.go new file mode 100644 index 0000000..a15658e --- /dev/null +++ b/pkg/backend/ghstack/backend_test.go @@ -0,0 +1,133 @@ +//go:build unix + +package ghstack + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/DomBlack/git-stack/pkg/exec" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" + "github.com/DomBlack/git-stack/pkg/stack" +) + +func TestSnapshotRestoreCommonFileOnly(t *testing.T) { + gittest.Isolate(t) + dir := gittest.InitRepo(t) + g := git.New(exec.New()) + repo, err := g.Discover(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + b := New(exec.New(), g) + path := filepath.Join(repo.CommonDir, FileName) + if err := os.WriteFile(path, []byte(mainFile), 0o644); err != nil { + t.Fatal(err) + } + snap, err := b.Snapshot(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if err := b.Update(context.Background(), repo, func(g *stack.Graph) error { + g.Stacks[0].Branches = g.Stacks[0].Branches[1:] + return nil + }); err != nil { + t.Fatal(err) + } + if err := b.Restore(context.Background(), repo, snap); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(path); string(got) != mainFile { + t.Errorf("restored file differs:\n%s", got) + } +} + +func TestSnapshotRestoreWorktreeFiles(t *testing.T) { + b, repo, mainPath, wtPath := updateFixture(t) + snap, err := b.Snapshot(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mainPath, []byte(`{"schemaVersion":1,"stacks":[]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(wtPath); err != nil { + t.Fatal(err) + } + if err := b.Restore(context.Background(), repo, snap); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(mainPath); string(got) != mainFile { + t.Errorf("main file differs:\n%s", got) + } + if got, _ := os.ReadFile(wtPath); string(got) != wtFile { + t.Errorf("worktree file differs:\n%s", got) + } +} + +// A worktree file written after the snapshot is removed by Restore, since +// it didn't exist when the snapshot was taken. +func TestRestoreRemovesFileThatDidNotExist(t *testing.T) { + b, repo, _, wtPath := updateFixture(t) + if err := os.Remove(wtPath); err != nil { + t.Fatal(err) + } + snap, err := b.Snapshot(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(wtPath, []byte(wtFile), 0o644); err != nil { + t.Fatal(err) + } + if err := b.Restore(context.Background(), repo, snap); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(wtPath); !os.IsNotExist(err) { + t.Errorf("the worktree file should be gone: %v", err) + } + g, err := b.Load(context.Background(), repo) + if err != nil || g.Tracked("w") { + t.Errorf("w should not be tracked after the restore: %+v %v", g, err) + } +} + +func TestRestoreWaitsForTheLock(t *testing.T) { + b, repo, mainPath, _ := updateFixture(t) + snap, err := b.Snapshot(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mainPath, []byte(`{"schemaVersion":1,"stacks":[]}`), 0o644); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(filepath.Join(repo.CommonDir, LockName), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- b.Restore(context.Background(), repo, snap) }() + select { + case err := <-done: + t.Fatalf("Restore finished while the lock was held: %v", err) + case <-time.After(200 * time.Millisecond): + } + if got, _ := os.ReadFile(mainPath); string(got) == mainFile { + t.Fatal("Restore wrote while the lock was held") + } + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + if err := <-done; err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(mainPath); string(got) != mainFile { + t.Errorf("main file differs:\n%s", got) + } +} diff --git a/pkg/git/objects.go b/pkg/git/objects.go index b9e52bc..f1d9303 100644 --- a/pkg/git/objects.go +++ b/pkg/git/objects.go @@ -99,6 +99,36 @@ func (c *Client) CommitTree(ctx context.Context, repo Repo, tree, parent string, return res.Out(), nil } +// keepAliveEnv names the author and committer of commits git-stack makes +// only to keep other commits reachable, so they never need the user's +// identity. +var keepAliveEnv = []string{ + "GIT_AUTHOR_NAME=git-stack", "GIT_AUTHOR_EMAIL=git-stack@localhost", + "GIT_COMMITTER_NAME=git-stack", "GIT_COMMITTER_EMAIL=git-stack@localhost", +} + +// EmptyTreeCommit creates a commit with an empty tree and the given +// parents. Nothing ever checks it out; a ref pointing at it keeps every +// parent from being garbage collected. +func (c *Client) EmptyTreeCommit(ctx context.Context, repo Repo, parents []string, msg string) (string, error) { + res, err := c.gitInput(ctx, repo, strings.NewReader(""), nil, "hash-object", "-w", "-t", "tree", "--stdin") + if err != nil { + return "", err + } + args := []string{"commit-tree", res.Out()} + for _, p := range parents { + args = append(args, "-p", p) + } + res, err = c.gitInput(ctx, repo, strings.NewReader(msg+"\n"), keepAliveEnv, append(args, "-F", "-")...) + if err != nil { + if ee, ok := errors.AsType[*exec.ExitError](err); ok { + return "", fmt.Errorf("commit-tree: %s", ee.Result.Err()) + } + return "", err + } + return res.Out(), nil +} + // PatchIDs maps the patch id of every non merge commit in from..to to that // commit, so two histories can be compared for the same changes regardless // of where they were rebased. The ids are verbatim: unlike --stable they diff --git a/pkg/git/refs.go b/pkg/git/refs.go index c054459..cf1f701 100644 --- a/pkg/git/refs.go +++ b/pkg/git/refs.go @@ -23,8 +23,9 @@ func (c *Client) Tip(ctx context.Context, repo Repo, ref string) (string, bool, return res.Out(), true, nil } -// RefUpdate moves Ref to New. When Old is set the ref must currently be at -// Old, and the whole transaction fails if any Old is stale. +// RefUpdate moves Ref to New, or deletes it when New is empty. When Old is +// set the ref must currently be at Old, and the whole transaction fails if +// any Old is stale. type RefUpdate struct { Ref, New, Old string } @@ -37,6 +38,10 @@ func (c *Client) UpdateRefs(ctx context.Context, repo Repo, updates []RefUpdate) var in strings.Builder in.WriteString("start\n") for _, u := range updates { + if u.New == "" { + fmt.Fprintf(&in, "delete %s %s\n", u.Ref, u.Old) + continue + } if u.Old != "" { fmt.Fprintf(&in, "update %s %s %s\n", u.Ref, u.New, u.Old) } else { @@ -76,6 +81,19 @@ func (c *Client) RenameBranch(ctx context.Context, repo Repo, from, to string) e return nil } +// ResetSoft moves the checked out branch to rev, leaving the index and the +// working tree alone. +func (c *Client) ResetSoft(ctx context.Context, repo Repo, rev string) error { + _, err := c.gitIn(ctx, repo, "reset", "--soft", "-q", rev) + if err != nil { + if ee, ok := errors.AsType[*exec.ExitError](err); ok && LockPath(err) == "" { + return fmt.Errorf("reset: %s", firstLine(ee.Result.Err())) + } + return err + } + return nil +} + // DeleteBranch deletes a local branch whether or not it is merged. git // refuses when the branch is checked out in any worktree; callers check. func (c *Client) DeleteBranch(ctx context.Context, repo Repo, name string) error { diff --git a/pkg/git/undo_refs_test.go b/pkg/git/undo_refs_test.go new file mode 100644 index 0000000..d5d56eb --- /dev/null +++ b/pkg/git/undo_refs_test.go @@ -0,0 +1,82 @@ +package git_test + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/DomBlack/git-stack/pkg/exec" + "github.com/DomBlack/git-stack/pkg/git" + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +func undoRefsFixture(t *testing.T) (*git.Client, git.Repo, string) { + t.Helper() + gittest.Isolate(t) + dir := gittest.InitRepo(t) + c := git.New(exec.New()) + repo, err := c.Discover(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + return c, repo, dir +} + +func TestEmptyTreeCommit(t *testing.T) { + c, repo, dir := undoRefsFixture(t) + ctx := context.Background() + one := gittest.Run(t, dir, "rev-parse", "HEAD") + two := gittest.Commit(t, dir, "x.txt", "x", "x") + sha, err := c.EmptyTreeCommit(ctx, repo, []string{one, two}, "keep these") + if err != nil { + t.Fatal(err) + } + parents := strings.Fields(gittest.Run(t, dir, "rev-list", "--parents", "-n1", sha))[1:] + if !slices.Equal(parents, []string{one, two}) { + t.Errorf("parents = %v", parents) + } + if files := gittest.Run(t, dir, "ls-tree", sha); files != "" { + t.Errorf("tree should be empty: %q", files) + } +} + +func TestUpdateRefsDeletes(t *testing.T) { + c, repo, dir := undoRefsFixture(t) + ctx := context.Background() + head := gittest.Run(t, dir, "rev-parse", "HEAD") + gittest.Run(t, dir, "branch", "gone") + if err := c.UpdateRefs(ctx, repo, []git.RefUpdate{{Ref: "refs/heads/gone", Old: head}}); err != nil { + t.Fatal(err) + } + if _, ok, _ := c.Tip(ctx, repo, "refs/heads/gone"); ok { + t.Error("gone should be deleted") + } +} + +func TestPointHEADAndResetSoft(t *testing.T) { + c, repo, dir := undoRefsFixture(t) + ctx := context.Background() + base := gittest.Run(t, dir, "rev-parse", "HEAD") + gittest.Run(t, dir, "switch", "-q", "-c", "feat") + gittest.Commit(t, dir, "f.txt", "f", "f") + if err := c.PointHEAD(ctx, repo, "main"); err != nil { + t.Fatal(err) + } + if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "main" { + t.Errorf("current = %s", cur) + } + if st := gittest.Run(t, dir, "status", "--short"); st != "A f.txt" { + t.Errorf("status after PointHEAD = %q", st) + } + gittest.Run(t, dir, "switch", "-q", "-f", "feat") + if err := c.ResetSoft(ctx, repo, base); err != nil { + t.Fatal(err) + } + if got := gittest.Run(t, dir, "rev-parse", "feat"); got != base { + t.Errorf("feat = %s, want %s", got, base) + } + if st := gittest.Run(t, dir, "status", "--short"); st != "A f.txt" { + t.Errorf("status after ResetSoft = %q", st) + } +} diff --git a/pkg/gitconfig/aliases.go b/pkg/gitconfig/aliases.go index 00d5e04..26ad8c2 100644 --- a/pkg/gitconfig/aliases.go +++ b/pkg/gitconfig/aliases.go @@ -46,6 +46,7 @@ var DefaultAliases = []Alias{ {"continue", "stack continue"}, {"abort", "stack abort"}, {"pr", "stack pr"}, + {"undo", "stack undo"}, {"c", "stack create"}, {"m", "stack modify"}, {"ab", "stack absorb"}, diff --git a/pkg/install/install_test.go b/pkg/install/install_test.go index 8162d86..390535a 100644 --- a/pkg/install/install_test.go +++ b/pkg/install/install_test.go @@ -192,7 +192,7 @@ func TestInstallAndUninstall(t *testing.T) { } func gitconfigDefaults() []string { - return []string{"create", "modify", "absorb", "reorder", "move", "fold", "rename", "untrack", "track", "pop", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "pr", "c", "m", "ab", "rs", "ss", "u", "d", "t", "b", "co"} + return []string{"create", "modify", "absorb", "reorder", "move", "fold", "rename", "untrack", "track", "pop", "restack", "submit", "sync", "up", "down", "top", "bottom", "continue", "abort", "pr", "undo", "c", "m", "ab", "rs", "ss", "u", "d", "t", "b", "co"} } func TestSkillOptInIsAsked(t *testing.T) { diff --git a/pkg/install/skill/SKILL.md b/pkg/install/skill/SKILL.md index 96c0ce7..dbaf111 100644 --- a/pkg/install/skill/SKILL.md +++ b/pkg/install/skill/SKILL.md @@ -52,6 +52,10 @@ the one calling. (its notices name each), so a move's source stack is grouped again by the same submit. A stack with fewer than two pull requests has nothing to group and the notice says so; a branch with no open pull request, or a failed create, gets a notice saying to submit from that stack. +- `stack_undo` takes back the last stack command run in this worktree (call it again to go + further back); after `stack_create`, `stack_modify` or `stack_absorb` the changes come back + staged. A successful `stack_submit` or `stack_merge` clears what can be undone. Only pass + `force: true` when the user has said to throw away commits made since. - Plain git is fine for reading (`git status`, `git diff`, `git log`) and for staging files. - Run `stack_view` before `stack_submit` so you know which branches get new PRs and need titles and bodies. @@ -61,5 +65,5 @@ the one calling. `git stack create -m "…"`, `git stack modify -a`, `git stack absorb`, `git stack reorder a b c`, `git stack move --onto `, `git stack fold`, `git stack rename `, `git stack untrack`, `git stack init a b`, `git stack pop`, `git stack restack`, `git stack up/down/top/bottom`, `git stack checkout`, `git stack submit --no-edit`, -`git stack sync -f`, `git stack pr --print` (a branch's PR URL; `stack_view` has them all). Aliases: `git c`, `git m`, `git ab`, `git reorder`, `git move`, `git fold`, `git rename`, `git untrack`, `git track`, `git pop`, -`git rs`, `git u`, `git d`, `git t`, `git b`, `git co`, `git ss`, `git pr`. +`git stack sync -f`, `git stack undo`, `git stack pr --print` (a branch's PR URL; `stack_view` has them all). Aliases: `git c`, `git m`, `git ab`, `git reorder`, `git move`, `git fold`, `git rename`, `git untrack`, `git track`, `git pop`, +`git rs`, `git u`, `git d`, `git t`, `git b`, `git co`, `git ss`, `git pr`, `git undo`. diff --git a/pkg/mcp/instructions.go b/pkg/mcp/instructions.go index d7dfaa8..d0c61d6 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), 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_rename gives the current branch a new name, renaming it on GitHub first once it has been pushed so its pull request follows (local: true renames it only in this repository); stack_init tracks branches you already have as a new stack, without rebasing; stack_untrack is its opposite, taking a branch and everything above it out of its stack without touching the branches (only: true takes just that branch and restacks the ones above onto its parent); 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. +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_rename gives the current branch a new name, renaming it on GitHub first once it has been pushed so its pull request follows (local: true renames it only in this repository); stack_init tracks branches you already have as a new stack, without rebasing; stack_untrack is its opposite, taking a branch and everything above it out of its stack without touching the branches (only: true takes just that branch and restacks the ones above onto its parent); 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_undo takes back the last of those commands run in this worktree (call it again to step further back; list: true shows what it would undo; a successful stack_submit or stack_merge clears it; only pass force: true when the user said to throw away commits made since); 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/server.go b/pkg/mcp/server.go index 316cf4e..bf983a0 100644 --- a/pkg/mcp/server.go +++ b/pkg/mcp/server.go @@ -227,6 +227,8 @@ var cliToTool = []struct{ from, to string }{ {"git stack pop", "stack_pop"}, {"git stack continue", "call stack_continue"}, {"git stack abort", "call stack_abort"}, + {"git stack undo --force", "stack_undo with force: true"}, + {"git stack undo", "stack_undo"}, {"git stack top", "stack_navigate {direction: \"top\"}"}, {"git stack checkout --trunk", "stack_navigate {direction: \"bottom\"} then {direction: \"down\"}"}, {"git stack checkout", "stack_navigate {branch: ...}"}, diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index f7aaa78..1bcb9ac 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -228,7 +228,7 @@ func TestToolsAreListedWithAnnotationsAndSchemas(t *testing.T) { for _, tool := range res.Tools { got[tool.Name] = tool } - for _, name := range []string{"stack_view", "stack_create", "stack_modify", "stack_absorb", "stack_reorder", "stack_move", "stack_fold", "stack_rename", "stack_untrack", "stack_init", "stack_pop", "stack_restack", "stack_navigate", "stack_submit", "stack_sync"} { + for _, name := range []string{"stack_view", "stack_create", "stack_modify", "stack_absorb", "stack_reorder", "stack_move", "stack_fold", "stack_rename", "stack_untrack", "stack_init", "stack_pop", "stack_restack", "stack_undo", "stack_navigate", "stack_submit", "stack_sync"} { if got[name] == nil { t.Errorf("missing tool %s", name) } diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 5cca66b..690687b 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -369,6 +369,26 @@ func (s *Server) pop(ctx context.Context, req *mcp.CallToolRequest, in popInput) return nil, res, nil } +// --- stack_undo ----------------------------------------------------------- + +type undoInput struct { + repoArg + List bool `json:"list,omitempty" jsonschema:"return the history, newest first, without changing anything"` + Force bool `json:"force,omitempty" jsonschema:"undo even when branches moved since the command, throwing away what changed on them; only with the user's say so"` +} + +func (s *Server) undo(ctx context.Context, req *mcp.CallToolRequest, in undoInput) (*mcp.CallToolResult, app.UndoResult, error) { + repo, a, err := s.appFor(ctx, req, in.RepoPath) + if err != nil { + return nil, app.UndoResult{}, wrapErr(err) + } + res, err := a.Undo(ctx, repo, app.UndoOptions{List: in.List, Force: in.Force}) + if err != nil { + return nil, app.UndoResult{}, wrapErr(err) + } + return nil, res, nil +} + // --- stack_continue / stack_abort --------------------------------------- type continueInput struct { @@ -698,6 +718,12 @@ func (s *Server) registerTools() { Description: "Give up the rebase a restack or modify stopped on and put back every branch the operation had already moved.", Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, }, s.abort) + mcp.AddTool(s.mcp, &mcp.Tool{ + Name: "stack_undo", + Title: "Undo the last git-stack command", + Description: "Undo the newest git-stack command run in this worktree that changed local state (create, modify, absorb, restack, reorder, move, fold, pop, untrack, init, sync, a local rename): branches, metadata and the checkout go back to how they were before it; call again to step further back, up to 20. After create, modify or absorb the changes they committed come back staged (staged: true); otherwise local changes are kept. list: true returns the history, newest first, with what each would put back, and changes nothing. It refuses (invalid_args) when nothing is left to undo (a successful stack_submit or stack_merge clears the history), when a branch moved since the command (force: true throws that away, only with the user's say so; uncommitted changes always stay) or when local changes are in the way, with nothing changed; and with rebase_active during a restack. It never touches GitHub.", + Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(true), OpenWorldHint: boolp(false)}, + }, s.undo) mcp.AddTool(s.mcp, &mcp.Tool{ Name: "stack_navigate", Title: "Navigate the stack", diff --git a/pkg/mcp/undo_test.go b/pkg/mcp/undo_test.go new file mode 100644 index 0000000..8ab3952 --- /dev/null +++ b/pkg/mcp/undo_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "testing" + + "github.com/DomBlack/git-stack/pkg/app" + "github.com/DomBlack/git-stack/pkg/git/gittest" +) + +func TestUndoTool(t *testing.T) { + h := newHarness(t) // on b + defer h.assertNoStdout() + if te := h.toolErr("stack_undo", nil); te.Code != "invalid_args" { + t.Errorf("nothing to undo = %+v", te) + } + before := gittest.Run(t, h.dir, "rev-parse", "b") + gittest.WriteFile(t, h.dir, "b.txt", "b2") + gittest.Run(t, h.dir, "add", "b.txt") + h.call("stack_modify", map[string]any{}, &app.ModifyResult{}) + + var list app.UndoResult + h.call("stack_undo", map[string]any{"list": true}, &list) + if len(list.Entries) != 1 || list.Entries[0].Command != "git stack modify" || len(list.Entries[0].Restores) == 0 { + t.Fatalf("list = %+v", list) + } + + // A commit on top since: refused, then forced. + gittest.Commit(t, h.dir, "later.txt", "later", "later") + te := h.toolErr("stack_undo", nil) + if te.Code != "invalid_args" || len(te.NextSteps) == 0 || te.NextSteps[0] != "stack_undo with force: true throws away what changed on them since (your uncommitted changes stay)" { + t.Errorf("drift = %+v", te) + } + var res app.UndoResult + h.call("stack_undo", map[string]any{"force": true}, &res) + if res.Undone == nil || res.Undone.Command != "git stack modify" || !res.Staged { + t.Errorf("undo = %+v", res) + } + if got := gittest.Run(t, h.dir, "rev-parse", "b"); got != before { + t.Errorf("b = %s, want %s", got, before) + } +} diff --git a/pkg/stack/ports.go b/pkg/stack/ports.go index f33ffd9..c45ebf3 100644 --- a/pkg/stack/ports.go +++ b/pkg/stack/ports.go @@ -46,6 +46,20 @@ type Metadata interface { // stacks; a stack left with no branches is removed. When fn returns an // error nothing is written and the error is returned. Update(ctx context.Context, repo git.Repo, fn func(*Graph) error) error + // Snapshot reads the metadata exactly as stored, under the backend's + // lock. + Snapshot(ctx context.Context, repo git.Repo) (Snapshot, error) + // Restore writes a snapshot back byte for byte under the lock, removing + // the files that did not exist when it was taken. + Restore(ctx context.Context, repo git.Repo, s Snapshot) error +} + +// Snapshot is the backend's metadata exactly as stored, opaque to everything +// but the backend that made it. +type Snapshot struct { + // Files maps each metadata file's path to its contents; an empty value + // records that the file did not exist. + Files map[string][]byte `json:"files"` } // Tracker registers branches with the backend. diff --git a/pkg/stack/stacktest/backend.go b/pkg/stack/stacktest/backend.go index 1565866..2e9d6b4 100644 --- a/pkg/stack/stacktest/backend.go +++ b/pkg/stack/stacktest/backend.go @@ -5,6 +5,7 @@ package stacktest import ( "context" + "encoding/json/v2" "slices" "sync" @@ -66,6 +67,44 @@ func (b *Backend) Update(_ context.Context, _ git.Repo, fn func(*stack.Graph) er return nil } +func (b *Backend) Snapshot(context.Context, git.Repo) (stack.Snapshot, error) { + return SnapshotGraph(b.Graph()) +} + +func (b *Backend) Restore(_ context.Context, _ git.Repo, s stack.Snapshot) error { + g, err := RestoreGraph(s) + if err != nil { + return err + } + b.SetGraph(g) + return nil +} + +// graphKey is the one file an in-memory snapshot holds. +const graphKey = "graph" + +// SnapshotGraph is an in-memory backend's Snapshot: a deep copy of g, as +// JSON, under a single key. +func SnapshotGraph(g *stack.Graph) (stack.Snapshot, error) { + data, err := json.Marshal(g.Stacks) + if err != nil { + return stack.Snapshot{}, err + } + return stack.Snapshot{Files: map[string][]byte{graphKey: data}}, nil +} + +// RestoreGraph is an in-memory backend's Restore: the graph SnapshotGraph +// copied. +func RestoreGraph(s stack.Snapshot) (*stack.Graph, error) { + var stacks []stack.Stack + if data := s.Files[graphKey]; len(data) > 0 { + if err := json.Unmarshal(data, &stacks); err != nil { + return nil, err + } + } + return stack.NewGraph(stacks), nil +} + func (b *Backend) InitStack(ctx context.Context, repo git.Repo, trunk string, branches []string) error { s := stack.Stack{Trunk: trunk} prev := trunk