Repository navigation
Undo the branch when create's commit fails #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,9 @@ import ( | |
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "slices" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/DomBlack/git-stack/pkg/ai" | ||
| "github.com/DomBlack/git-stack/pkg/git" | ||
|
|
@@ -36,6 +38,10 @@ type CreateResult struct { | |
| // recentSubjectsCount is how many trunk subjects feed the AI prompt. | ||
| const recentSubjectsCount = 10 | ||
|
|
||
| // undoTimeout bounds the undo of a failed create, which runs even when the | ||
| // command's own context was cancelled. | ||
| const undoTimeout = 30 * time.Second | ||
|
|
||
| // Create stages, creates a branch on top of the current one, commits and | ||
| // registers the branch with the backend. | ||
| func (a *App) Create(ctx context.Context, repo git.Repo, o CreateOptions) (CreateResult, error) { | ||
|
|
@@ -96,13 +102,65 @@ func (a *App) Create(ctx context.Context, repo git.Repo, o CreateOptions) (Creat | |
| } | ||
| sha, err := a.d.Git.Commit(ctx, repo, git.CommitOptions{Message: message, NoVerify: o.NoVerify}) | ||
| if err != nil { | ||
| return res, commitError(err, "pass -m <message> (the branch was created; commit with git commit)") | ||
| return CreateResult{}, a.undoCreate(ctx, repo, name, current, commitError(err, "pass -m <message>")) | ||
|
Copilot marked this conversation as resolved.
|
||
| } | ||
| info := a.commitInfo(ctx, repo, sha) | ||
| res.Commit = &info | ||
| return res, nil | ||
| } | ||
|
|
||
| // undoCreate makes a failed create all or nothing: the branch was already | ||
| // created, checked out and registered before the commit failed (a locked | ||
| // signing key, a hook saying no), so forget it, go back to the parent and | ||
| // delete it. The staged changes survive, both branches point at the same | ||
| // commit. The error says to run create again, or, when undoing failed too, | ||
| // what was left behind and how to commit on it instead. | ||
| func (a *App) undoCreate(ctx context.Context, repo git.Repo, name, parent string, cause error) error { | ||
| err := asStackError(cause) | ||
| // The commit may have failed because ctx was cancelled; the undo still | ||
| // has to run, so give it its own bounded context. | ||
| ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), undoTimeout) | ||
| defer cancel() | ||
| // Commit can fail after git has actually committed (reading HEAD back | ||
| // under a cancelled context, say). Then the branch holds the commit | ||
| // and must stay: deleting it would orphan the work. | ||
| if tip, ok, terr := a.d.Git.Tip(ctx, repo, name); terr == nil && ok { | ||
| if parentTip, _, perr := a.d.Git.Tip(ctx, repo, parent); perr == nil && tip != parentTip { | ||
|
Comment on lines
+127
to
+128
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If git can't resolve either ref, the undo's own git calls fail the same way and the error reports what was left behind rather than silently deleting anything, since each step checks its error. A branch -D after a commit that did land is also still in the reflog. Not going to add more ceremony for that corner. |
||
| return err.WithSteps(fmt.Sprintf("%s has a commit after all, so it was kept and is tracked; check `git stack log`", name)) | ||
| } | ||
| } | ||
| undo := func() error { | ||
| if err := a.d.Meta.Update(ctx, repo, func(g *stack.Graph) error { | ||
| for i := range g.Stacks { | ||
| g.Stacks[i].Branches = slices.DeleteFunc(g.Stacks[i].Branches, func(b stack.Branch) bool { return b.Name == name }) | ||
| } | ||
| return nil | ||
| }); err != nil { | ||
| return fmt.Errorf("forget %s: %w", name, err) | ||
| } | ||
| if err := a.d.Git.Switch(ctx, repo, parent); err != nil { | ||
| return err | ||
| } | ||
| return a.d.Git.DeleteBranch(ctx, repo, name) | ||
|
Copilot marked this conversation as resolved.
|
||
| } | ||
| if undoErr := undo(); undoErr != nil { | ||
| a.d.Log.Debug("undo create", "branch", name, "err", undoErr) | ||
| return err.WithSteps( | ||
| fmt.Sprintf("%s was left behind with no commit of its own (undoing it failed: %v)", name, undoErr), | ||
| "check `git stack log`; if it is still tracked, commit on it with `git stack modify -c -m <message>`, otherwise delete it") | ||
| } | ||
| return err.WithSteps("nothing was created; run `git stack create` again once that is sorted") | ||
| } | ||
|
|
||
| // asStackError returns err as a *stack.Error, wrapping anything else as | ||
| // KindUnknown so next steps can be attached. | ||
| func asStackError(err error) *stack.Error { | ||
| if se, ok := errors.AsType[*stack.Error](err); ok { | ||
| return se | ||
| } | ||
| return stack.New(stack.KindUnknown, err.Error()).WithCause(err) | ||
| } | ||
|
|
||
| // isDefaultBranch reports whether name is the repository's default branch | ||
| // while no stack has declared a trunk yet (the first stack starts here). | ||
| func (a *App) isDefaultBranch(ctx context.Context, repo git.Repo, graph *stack.Graph, name string) bool { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -435,7 +435,7 @@ func (s *Server) registerTools() { | |
| mcp.AddTool(s.mcp, &mcp.Tool{ | ||
| Name: "stack_create", | ||
| Title: "Create a stacked branch", | ||
| Description: "Create a new branch on top of the current branch, commit staged changes with the given message, and register it in the stack. From the trunk this starts a new stack. Fails with not_at_top when the current branch is not the top of its stack.", | ||
| Description: "Create a new branch on top of the current branch, commit staged changes with the given message, and register it in the stack. From the trunk this starts a new stack. Fails with not_at_top when the current branch is not the top of its stack. If the commit fails (signing_failed, a hook) the branch is removed again and the changes stay staged, so fix the cause and call it again; should removing it fail too, next_steps says what was left behind and how to commit on it.", | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same answer as on the architecture note: next_steps for the failed-undo case already distinguishes still tracked (modify -c) from untracked (delete it), so a client reads that rather than the description. Leaving the description as the summary. |
||
| Annotations: &mcp.ToolAnnotations{DestructiveHint: boolp(false), OpenWorldHint: boolp(false)}, | ||
| }, s.create) | ||
| mcp.AddTool(s.mcp, &mcp.Tool{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The step text already covers both states: it says to check git stack log, commit with modify -c if the branch is still tracked, otherwise delete it. The architecture note is the summary; I'll leave it at that.