rebase continue: recover from rebase conflicts (#115)

This adds support for continuing or aborting an interrupted rebase-ish
operation with two new commands:

    gs rebase continue  # alias: gs rbc
    gs rebase abort     # alias: gs rba

The general idea is that when a rebase-ish operation is interrupted,
we'll record the command that should run after the rebase is resolved,
and the user can run 'gs rebase continue' to continue the operation.

This works for all commands that call `git rebase` under the hood,
including convenience commands like `gs commit create`.

Resolves #42
This commit is contained in:
Abhinav Gupta
2024-05-27 20:36:09 -07:00
committed by GitHub
parent 93a2f3255e
commit eec6e28ace
30 changed files with 1114 additions and 153 deletions
@@ -0,0 +1,3 @@
kind: Added
body: Add `gs rebase continue` (alias `gs rbc`) and `gs rebase abort` (alias `gs rba`) to continue git-spice operations interrupted by rebase conflicts.
time: 2024-05-27T20:21:19.22906-07:00
@@ -0,0 +1,3 @@
kind: Changed
body: 'branch {edit, onto}: Support continuing the operation after resolving conflicts with `gs rebase continue`.'
time: 2024-05-27T20:21:55.380558-07:00
@@ -0,0 +1,3 @@
kind: Changed
body: '{branch, upstack, stack} restack: Support continuing the operation after resolving conflicts with `gs rebase continue`.'
time: 2024-05-27T20:23:08.650923-07:00
+39
View File
@@ -13,6 +13,45 @@
and add a link from the old entry to the new one.
-->
## 2024-05-27: Continuing operations with `gs rebase continue`
A number of git-spice commands run `git rebase` under the hood.
These rebase operations can be interrupted by conflicts, or
for interactive rebases, by the user adding an `edit` or `break` instruction.
We offer a `gs rebase continue` command to resume the interrupted operation.
For this, we need to track the "continuation command":
the command that must be run after the conflict is resolved.
Different commands have different continuation commands:
- `branch restack`:
Re-run the original command.
This will verify that the branch is restacked and update internal state.
- `stack restack`, `upstack restack`, `downstack restack`:
Re-run the original command.
This will skip branches that are already restacked,
and continue restacking the remaining branches.
- `branch onto`:
Re-run the original command.
This will verify that the branch was moved, and update internal state.
- `branch edit`: Run `upstack restack`.
All but `branch edit` re-run the original command to continue,
but this divergence means we have to allow for something other than
"re-run the original command."
For this, we can track a new file in the git spice state: `rebase-continue`.
If this file exists, it will contain:
{
command: []string, // gs command to run
branch: string?, // branch to run the command on
}
`gs rebase continue` will check out `$branch` and run `gs ${args}`
in a loop until the file doesn't exist.
## 2024-05-18: Branch state tracks upstream branch name
It's possible for a branch to be renamed locally after a `gs branch submit`.
+16 -9
View File
@@ -16,8 +16,11 @@ type branchEditCmd struct{}
func (*branchEditCmd) Help() string {
return text.Dedent(`
Allows editing the commits in the current branch
with an interactive rebase.
Begins an interactive rebase of a branch without affecting its
base branch. This allows you to edit the commits in the branch,
reword their messages, etc.
After the rebase, the branches upstack from the edited branch
will be restacked.
`)
}
@@ -49,17 +52,21 @@ func (*branchEditCmd) Run(ctx context.Context, log *log.Logger, opts *globalOpti
return fmt.Errorf("get branch: %w", err)
}
if err := repo.Rebase(ctx, git.RebaseRequest{
req := git.RebaseRequest{
Interactive: true,
Branch: currentBranch,
Upstream: b.Base,
}); err != nil {
return fmt.Errorf("rebase: %w", err)
}
// TODO: if, when rebase returns, we're in the middle of a rebase,
// print a message informing the user that they should run
// `gs continue` after they've finished the rebase operation.
if err := repo.Rebase(ctx, req); err != nil {
// if the rebase is interrupted,
// recover with an 'upstack restack' later.
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: err,
Command: []string{"upstack", "restack"},
Branch: currentBranch,
Message: fmt.Sprintf("interrupted: edit branch %s", currentBranch),
})
}
return (&upstackRestackCmd{}).Run(ctx, log, opts)
}
+8 -3
View File
@@ -102,11 +102,16 @@ func (cmd *branchOntoCmd) Run(ctx context.Context, log *log.Logger, opts *global
Autostash: true,
Quiet: true,
}); err != nil {
return fmt.Errorf("rebase: %w", err)
// If the rebase is interrupted,
// we'll just re-run this command again later.
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: err,
Command: []string{"branch", "onto", cmd.Onto},
Branch: cmd.Branch,
Message: fmt.Sprintf("interrupted: branch %s onto %s", cmd.Branch, cmd.Onto),
})
}
// TODO: handle conflicts/partial rebase
err = store.Update(ctx, &state.UpdateRequest{
Upserts: []state.UpsertRequest{
{
+10
View File
@@ -47,7 +47,17 @@ func (cmd *branchRestackCmd) Run(ctx context.Context, log *log.Logger, opts *glo
svc := spice.NewService(repo, store, log)
res, err := svc.Restack(ctx, cmd.Name)
if err != nil {
var rebaseErr *git.RebaseInterruptError
switch {
case errors.As(err, &rebaseErr):
// If the rebase is interrupted by a conflict,
// we'll resume by re-running this command.
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: rebaseErr,
Command: []string{"branch", "restack", cmd.Name},
Branch: cmd.Name,
Message: fmt.Sprintf("interrupted: restack branch %s", cmd.Name),
})
case errors.Is(err, state.ErrNotExist):
log.Errorf("%v: branch not tracked: run 'gs branch track'", cmd.Name)
return errors.New("untracked branch")
+1 -1
View File
@@ -39,7 +39,7 @@ func (cmd *commitAmendCmd) Run(ctx context.Context, log *log.Logger, opts *globa
return fmt.Errorf("commit: %w", err)
}
if _, err := repo.RebaseState(); err == nil {
if _, err := repo.RebaseState(ctx); err == nil {
// In the middle of a rebase.
// Don't restack upstack branches.
return nil
+1 -1
View File
@@ -38,7 +38,7 @@ func (cmd *commitCreateCmd) Run(ctx context.Context, log *log.Logger, opts *glob
return fmt.Errorf("commit: %w", err)
}
if _, err := repo.RebaseState(); err == nil {
if _, err := repo.RebaseState(ctx); err == nil {
// In the middle of a rebase.
// Don't restack upstack branches.
return nil
+65 -28
View File
@@ -14,6 +14,32 @@ gs (git-spice) is a command line tool for stacking Git branches.
* `-C`, `--dir=DIR`: Change to DIR before doing anything
* `--[no-]prompt`: Whether to prompt for missing information
## gs completion
```
gs completion <shell> [flags]
```
Generate shell completion script
Generates shell completion scripts.
To install the script, add the output of this command to your
shell's rc file.
For example:
# bash
gs completion bash >> ~/.bashrc
# zsh
gs completion zsh >> ~/.zshrc
# fish
gs completion fish >> ~/.config/fish/config.fish
**Arguments**
* `shell`: Shell to generate completions for.
## gs repo init
```
@@ -297,8 +323,11 @@ gs branch (b) edit (e)
Edit the commits in a branch
Allows editing the commits in the current branch
with an interactive rebase.
Begins an interactive rebase of a branch without affecting its
base branch. This allows you to edit the commits in the branch,
reword their messages, etc.
After the rebase, the branches upstack from the edited branch
will be restacked.
## gs branch rename
@@ -398,6 +427,40 @@ as you update a branch in the middle of the stack.
* `-m`, `--message=STRING`: Use the given message as the commit message.
* `-n`, `--no-edit`: Don't edit the commit message
## gs rebase continue
```
gs rebase (rb) continue (c)
```
Continue an interrupted operation
This command continues an ongoing git-spice operation that was
interrupted by a Git rebase action.
Without an ongoing git-spice operation,
this is equivalent to 'git rebase --continue'.
For example, if 'gs upstack restack' encounters a conflict,
resolve the conflict and run 'gs rebase continue'
(or its shorthand 'gs rbc') to continue the operation.
## gs rebase abort
```
gs rebase (rb) abort (a)
```
Abort an operation
This command cancels an ongoing git-spice operation that was
interrupted by a Git rebase action.
Without an ongoing git-spice operation,
this is equivalent to 'git rebase --abort'.
For example, if 'gs upstack restack' encounters a conflict,
cancel the operation with 'gs rebase abort'
(or its shorthand 'gs rba').
## gs up
```
@@ -462,29 +525,3 @@ gs trunk [flags]
Move to the trunk branch
## gs completion
```
gs completion <shell> [flags]
```
Generate shell completion script
Generates shell completion scripts.
To install the script, add the output of this command to your
shell's rc file.
For example:
# bash
gs completion bash >> ~/.bashrc
# zsh
gs completion zsh >> ~/.zshrc
# fish
gs completion fish >> ~/.config/fish/config.fish
**Arguments**
* `shell`: Shell to generate completions for.
+90 -51
View File
@@ -12,9 +12,53 @@ import (
"go.abhg.dev/gs/internal/must"
)
// ErrRebaseInterrupted is returned when a rebase operation is interrupted
// because of a
var ErrRebaseInterrupted = errors.New("rebase interrupted")
// RebaseInterruptKind specifies the kind of rebase interruption.
type RebaseInterruptKind int
const (
// RebaseInterruptConflict indicates that a rebase operation
// was interrupted due to a conflict.
RebaseInterruptConflict RebaseInterruptKind = iota
// RebaseInterruptDeliberate indicates that a rebase operation
// was interrupted deliberately by the user.
// This is usually done to edit the rebase instructions.
RebaseInterruptDeliberate
)
// RebaseInterruptError indicates that a rebasing operation was interrupted.
// It includes the kind of interruption and the current rebase state.
type RebaseInterruptError struct {
Kind RebaseInterruptKind
State *RebaseState // always non-nil
// Err is non-nil only if the rebase operation failed
// due to a conflict.
Err error
}
func (e *RebaseInterruptError) Error() string {
var msg strings.Builder
msg.WriteString("rebase")
if e.State != nil {
fmt.Fprintf(&msg, " of %s", e.State.Branch)
}
msg.WriteString(" interrupted")
switch e.Kind {
case RebaseInterruptConflict:
msg.WriteString(" by a conflict")
case RebaseInterruptDeliberate:
msg.WriteString(" deliberately")
}
if e.Err != nil {
fmt.Fprintf(&msg, ": %v", e.Err)
}
return msg.String()
}
func (e *RebaseInterruptError) Unwrap() error {
return e.Err
}
// RebaseRequest is a request to rebase a branch.
type RebaseRequest struct {
@@ -43,31 +87,11 @@ type RebaseRequest struct {
// with a list of rebase instructions to edit
// before starting the rebase operation.
Interactive bool
// InterruptFunc, if set, is called if a rebase operation
// is interrupted because of a conflict,
// or because the user an instruction to pause the rebase
// (e.g. 'edit' or 'break').
//
// The Rebase function will return the error returned by this function.
InterruptFunc func(context.Context, *RebaseState, RebaseInterruptKind) error
}
// RebaseInterruptKind specifies the kind of rebase interrupt.
type RebaseInterruptKind int
const (
// RebaseInterruptDeliberate indicates that the rebase was interrupted
// because the user deliberately paused the rebase operation
// (e.g. by using the 'edit' or 'break' instruction).
RebaseInterruptDeliberate RebaseInterruptKind = iota
// RebaseInterruptConflict indicates that the rebase was interrupted
// because of a conflict.
RebaseInterruptConflict
)
// Rebase runs a git rebase operation with the specified parameters.
// It returns [ErrRebaseInterrupted] or [ErrRebaseConflict] for known
// rebase interruptions.
func (r *Repository) Rebase(ctx context.Context, req RebaseRequest) error {
args := []string{"rebase"}
if req.Interactive {
@@ -91,41 +115,56 @@ func (r *Repository) Rebase(ctx context.Context, req RebaseRequest) error {
cmd := r.gitCmd(ctx, args...)
if req.Interactive {
cmd.Stdin(os.Stdin).Stdout(os.Stdout).Stderr(os.Stderr)
cmd.Stdin(os.Stdin).Stdout(os.Stdout)
}
if err := cmd.Run(r.exec); err != nil {
originalErr := err
if exitErr := new(exec.ExitError); !errors.As(err, &exitErr) {
return fmt.Errorf("rebase: %w", err)
}
return r.handleRebaseError(ctx, err)
}
return r.handleRebaseFinish(ctx)
}
// If the rebase operation actually ran, but failed,
// we might be in the middle of a rebase operation.
state, err := r.RebaseState()
if err != nil {
// Rebase probably failed for a different reason,
// so no need to log the state read failure verbosely.
r.log.Debug("Failed to read rebase state: %v", err)
return originalErr
}
// RebaseContinue continues an ongoing rebase operation.
func (r *Repository) RebaseContinue(ctx context.Context) error {
cmd := r.gitCmd(ctx, "rebase", "--continue").Stdin(os.Stdin).Stdout(os.Stdout)
if err := cmd.Run(r.exec); err != nil {
return r.handleRebaseError(ctx, err)
}
return r.handleRebaseFinish(ctx)
}
if req.InterruptFunc == nil {
// The rebase failed, but we don't have a way to handle it.
// Return ErrRebaseInterrupted.
return errors.Join(ErrRebaseInterrupted, originalErr)
}
return req.InterruptFunc(ctx, state, RebaseInterruptConflict)
func (r *Repository) handleRebaseError(ctx context.Context, err error) error {
originalErr := err
if exitErr := new(exec.ExitError); !errors.As(err, &exitErr) {
return fmt.Errorf("rebase: %w", err)
}
// If the rebase operation actually ran, but failed,
// we might be in the middle of a rebase operation.
state, err := r.RebaseState(ctx)
if err != nil {
// Rebase probably failed for a different reason,
// so no need to log the state read failure verbosely.
r.log.Debug("Failed to read rebase state: %v", err)
return originalErr
}
return &RebaseInterruptError{
Err: originalErr,
Kind: RebaseInterruptConflict,
State: state,
}
}
func (r *Repository) handleRebaseFinish(ctx context.Context) error {
// If we have rebase state after a successful return,
// this was a deliberate break or edit.
if state, err := r.RebaseState(); err == nil {
if req.InterruptFunc == nil {
return ErrRebaseInterrupted
if state, err := r.RebaseState(ctx); err == nil {
return &RebaseInterruptError{
Kind: RebaseInterruptDeliberate,
State: state,
// TODO: should we include stderr as an Error
}
return req.InterruptFunc(ctx, state, RebaseInterruptDeliberate)
}
return nil
@@ -184,7 +223,7 @@ var ErrNoRebase = errors.New("no rebase in progress")
// RebaseState loads information about an ongoing rebase,
// or [ErrNoRebase] if no rebase is in progress.
func (r *Repository) RebaseState() (*RebaseState, error) {
func (r *Repository) RebaseState(context.Context) (*RebaseState, error) {
// Rebase state is stored inside .git/rebase-merge or .git/rebase-apply
// depending on the backend in use.
// See https://github.com/git/git/blob/d8ab1d464d07baa30e5a180eb33b3f9aa5c93adf/wt-status.c#L1711.
+10 -54
View File
@@ -63,7 +63,7 @@ func TestRebase_deliberateInterrupt(t *testing.T) {
login(t, "foo")
// Test cases with no InterruptFunc.
// All must see ErrRebaseInterrupted.
// All must see RebseInterruptError.
noFuncTests := []struct {
name string
lines []string
@@ -99,37 +99,13 @@ func TestRebase_deliberateInterrupt(t *testing.T) {
Interactive: true,
})
require.Error(t, err)
assert.ErrorIs(t, err, git.ErrRebaseInterrupted)
var rebaseErr *git.RebaseInterruptError
require.ErrorAs(t, err, &rebaseErr)
assert.Equal(t, &git.RebaseState{Branch: "feature"}, rebaseErr.State)
assert.Equal(t, git.RebaseInterruptDeliberate, rebaseErr.Kind)
})
}
t.Run("InterruptFunc", func(t *testing.T) {
defer func() {
assert.NoError(t, repo.RebaseAbort(ctx))
}()
// Either test case will do.
mockedit.Expect(t).
GiveLines(noFuncTests[0].lines...)
var calledInterrupt bool
defer func() {
assert.True(t, calledInterrupt, "InterruptFunc was not called")
}()
err = repo.Rebase(ctx, git.RebaseRequest{
Branch: "feature",
Upstream: "main",
Interactive: true,
InterruptFunc: func(_ context.Context, state *git.RebaseState, kind git.RebaseInterruptKind) error {
calledInterrupt = true
assert.Equal(t, &git.RebaseState{Branch: "feature"}, state)
assert.Equal(t, git.RebaseInterruptDeliberate, kind)
return nil
},
})
})
}
func TestRebase_unexpectedInterrupt(t *testing.T) {
@@ -181,31 +157,11 @@ func TestRebase_unexpectedInterrupt(t *testing.T) {
Upstream: "main",
})
require.Error(t, err)
assert.ErrorIs(t, err, git.ErrRebaseInterrupted)
})
t.Run("InterruptFunc", func(t *testing.T) {
defer func() {
assert.NoError(t, repo.RebaseAbort(ctx))
}()
var calledInterrupt bool
defer func() {
assert.True(t, calledInterrupt, "InterruptFunc was not called")
}()
err = repo.Rebase(ctx, git.RebaseRequest{
Branch: "feature",
Upstream: "main",
InterruptFunc: func(_ context.Context, state *git.RebaseState, kind git.RebaseInterruptKind) error {
calledInterrupt = true
assert.Equal(t, &git.RebaseState{Branch: "feature"}, state)
assert.Equal(t, git.RebaseInterruptConflict, kind)
return nil
},
})
require.NoError(t, err)
var rebaseErr *git.RebaseInterruptError
require.ErrorAs(t, err, &rebaseErr)
assert.Equal(t, &git.RebaseState{Branch: "feature"}, rebaseErr.State)
assert.Equal(t, git.RebaseInterruptConflict, rebaseErr.Kind)
})
}
+107
View File
@@ -0,0 +1,107 @@
package spice
import (
"context"
"errors"
"fmt"
"strings"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/spice/state"
)
// ErrRebaseInterrupted indicates that a rebase operation was interrupted.
var ErrRebaseInterrupted = errors.New("rebase interrupted")
// RebaseRescueRequest is a request to rescue a rebase operation.
type RebaseRescueRequest struct {
// Err is the error that caused the rebase operation to be interrupted.
Err error
// Command is the command that should be run
// after the rebase operation has been rescued.
//
// If this is unset, a continuation will NOT be recorded.
Command []string
// Branch is the branch on which the command should be run.
//
// If this is unset, the continuation will run on the interrupted
// branch.
Branch string
// Message is the message that should be recorded
// for debugging this continuation.
Message string // optional
}
// RebaseRescue attempts to recover a git-spice operation that was interrupted
// by a rebase conflict or other interruption.
// If it determines that the rebase can be recovered from and continued in the
// future, it records the continuation command in the data store for later
// resumption.
//
// This returns [ErrRebaseInterrupted] if the rebase was recovered from
// so that the program can exit and the oepration can resume later.
func (s *Service) RebaseRescue(ctx context.Context, req RebaseRescueRequest) error {
if req.Err == nil {
return nil
}
var rebaseErr *git.RebaseInterruptError
if !errors.As(req.Err, &rebaseErr) {
return req.Err
}
// TODO: This will also log git's standard advice for resolving conflicts.
// We could suppress that by setting advice.mergeConflict=false
// during the rebase operation.
s.log.Warn("rebase interrupted", "error", rebaseErr)
switch rebaseErr.Kind {
case git.RebaseInterruptConflict:
var msg strings.Builder
fmt.Fprintf(&msg, "There was a conflict while rebasing.\n")
fmt.Fprintf(&msg, "Resolve the conflict and run:\n")
fmt.Fprintf(&msg, " gs rebase continue\n")
fmt.Fprintf(&msg, "Or abort the operation with:\n")
fmt.Fprintf(&msg, " gs rebase abort\n")
s.log.Error(msg.String())
case git.RebaseInterruptDeliberate:
var msg strings.Builder
fmt.Fprintf(&msg, "The rebase operation was interrupted with an 'edit' or 'break' command.\n")
fmt.Fprintf(&msg, "When you're ready to continue, run:\n")
fmt.Fprintf(&msg, " gs rebase continue\n")
fmt.Fprintf(&msg, "Or abort the operation with:\n")
fmt.Fprintf(&msg, " gs rebase abort\n")
s.log.Info(msg.String())
default:
must.Failf("unexpected rebase interrupt kind: %v", rebaseErr.Kind)
}
// No continuation to record.
if len(req.Command) == 0 {
return ErrRebaseInterrupted
}
branch := req.Branch
if branch == "" {
branch = rebaseErr.State.Branch
}
msg := req.Message
if msg == "" {
msg = fmt.Sprintf("interrupted: branch %s", req.Branch)
}
if err := s.store.SetContinuation(ctx, state.SetContinuationRequest{
Command: req.Command,
Branch: branch,
Message: msg,
}); err != nil {
return fmt.Errorf("edit state: %w", err)
}
return ErrRebaseInterrupted
}
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *Service) Restack(ctx context.Context, name string) (*RestackResponse, e
}); err != nil {
return nil, fmt.Errorf("rebase: %w", err)
// TODO: detect conflicts in rebase,
// print message about "gs continue"
// print message about "gs rebase continue"
}
err = s.store.Update(ctx, &state.UpdateRequest{
+2
View File
@@ -63,6 +63,8 @@ type BranchStore interface {
// Trunk returns the name of the trunk branch.
Trunk() string
SetContinuation(context.Context, state.SetContinuationRequest) error
}
var _ BranchStore = (*state.Store)(nil)
+98
View File
@@ -0,0 +1,98 @@
package state
import (
"context"
"errors"
"fmt"
"go.abhg.dev/gs/internal/must"
)
// SetContinuationRequest is a request to set the operation
// that should run after the current rebase finishes successfully.
type SetContinuationRequest struct {
// Branch is the branch on which the operation should run.
Branch string // required
// Command specifies the gs command that will be run.
Command []string // required
// Message is a message for the gs state log.
Message string
}
// SetContinuation records a command that should run
// when an interrupted rebase operation is resumed.
func (s *Store) SetContinuation(ctx context.Context, req SetContinuationRequest) error {
must.NotBeBlankf(req.Branch, "a branch name is required")
must.NotBeEmptyf(req.Command, "arguments for git-spice are required")
if req.Message == "" {
req.Message = "set rebase continuation"
}
// Sanity check:
// Must not have an existing continuation.
var cont rebaseContinuation
if err := s.b.Get(ctx, _rebaseContinueJSON, &cont); err == nil {
s.log.Errorf("Found an existing rebase continuation for %v: %q", cont.Branch, cont.Command)
return errors.New("an unfinished rebase continuation already exists")
// TODO: If we encounter this in practice from a normal workflow,
// we'll probably want a queue or stack for continuations.
}
cont = rebaseContinuation{
Branch: req.Branch,
Command: req.Command,
}
if err := s.b.Update(ctx, updateRequest{
Sets: []setRequest{
{Key: _rebaseContinueJSON, Val: cont},
},
Msg: req.Message,
}); err != nil {
return fmt.Errorf("set rebase continuation: %w", err)
}
return nil
}
// TakeContinuationResult includes the information needed to resume a
// rebase operation that was interrupted.
type TakeContinuationResult struct {
// Command specifies the arguments for the gs operation
// that was interrupted.
Command []string
// Branch is the branch that the command should be run on.
Branch string
}
// TakeContinuation removes a recorded rebase continuation from the store
// and returns it.
//
// If there is no continuation, it returns nil.
func (s *Store) TakeContinuation(ctx context.Context, msg string) (*TakeContinuationResult, error) {
if msg == "" {
msg = "take rebase continuation"
}
var cont rebaseContinuation
if err := s.b.Get(ctx, _rebaseContinueJSON, &cont); err != nil {
if errors.Is(err, ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("get rebase continuation: %w", err)
}
if err := s.b.Update(ctx, updateRequest{
Dels: []string{_rebaseContinueJSON},
Msg: msg,
}); err != nil {
return nil, fmt.Errorf("delete rebase continuation: %w", err)
}
return &TakeContinuationResult{
Command: cont.Command,
Branch: cont.Branch,
}, nil
}
+11 -2
View File
@@ -6,8 +6,9 @@ import (
)
const (
_repoJSON = "repo"
_branchesDir = "branches"
_repoJSON = "repo"
_branchesDir = "branches"
_rebaseContinueJSON = "rebase-continue"
)
type repoInfo struct {
@@ -22,6 +23,14 @@ func (i *repoInfo) Validate() error {
return nil
}
type rebaseContinuation struct {
// Command is the gs command that will be run.
Command []string `json:"command"`
// Branch on which the command must be run.
Branch string `json:"branch"`
}
type branchStateBase struct {
Name string `json:"name"`
Hash string `json:"hash"`
+4 -3
View File
@@ -186,6 +186,8 @@ type globalOptions struct {
type mainCmd struct {
globalOptions `group:"globals"`
Completion completionCmd `cmd:"" group:"Setup" help:"Generate shell completion script"`
Repo repoCmd `cmd:"" aliases:"r" group:"Repository"`
Stack stackCmd `cmd:"" aliases:"s" group:"Stack"`
@@ -195,6 +197,8 @@ type mainCmd struct {
Branch branchCmd `cmd:"" aliases:"b" group:"Branch"`
Commit commitCmd `cmd:"" aliases:"c" group:"Commit"`
Rebase rebaseCmd `cmd:"" aliases:"rb" group:"Rebase"`
// Navigation
Up upCmd `cmd:"" aliases:"u" group:"Navigation" help:"Move up one branch"`
Down downCmd `cmd:"" aliases:"d" group:"Navigation" help:"Move down one branch"`
@@ -202,9 +206,6 @@ type mainCmd struct {
Bottom bottomCmd `cmd:"" aliases:"D" group:"Navigation" help:"Move to the bottom of the stack"`
Trunk trunkCmd `cmd:"" group:"Navigation" help:"Move to the trunk branch"`
// Other
Completion completionCmd `name:"completion" cmd:"" group:"System" help:"Generate shell completion script"`
// Hidden commands:
DumpMD dumpMarkdownCmd `name:"dump-md" hidden:"" cmd:"" help:"Dump a Markdown reference to stdout and quit"`
}
+6
View File
@@ -0,0 +1,6 @@
package main
type rebaseCmd struct {
Continue rebaseContinueCmd `aliases:"c" cmd:"" help:"Continue an interrupted operation"`
Abort rebaseAbortCmd `aliases:"a" cmd:"" help:"Abort an operation"`
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/charmbracelet/log"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/text"
)
type rebaseAbortCmd struct{}
func (*rebaseAbortCmd) Help() string {
return text.Dedent(`
This command cancels an ongoing git-spice operation that was
interrupted by a Git rebase action.
Without an ongoing git-spice operation,
this is equivalent to 'git rebase --abort'.
For example, if 'gs upstack restack' encounters a conflict,
cancel the operation with 'gs rebase abort'
(or its shorthand 'gs rba').
`)
}
func (cmd *rebaseAbortCmd) Run(ctx context.Context, log *log.Logger, opts *globalOptions) error {
repo, err := git.Open(ctx, ".", git.OpenOptions{
Log: log,
})
if err != nil {
return fmt.Errorf("open repository: %w", err)
}
store, err := ensureStore(ctx, repo, log, opts)
if err != nil {
return err
}
var wasRebasing bool
if _, err := repo.RebaseState(ctx); err != nil {
if !errors.Is(err, git.ErrNoRebase) {
return fmt.Errorf("get rebase state: %w", err)
}
// If the user ran 'git rebase --abort' instead,
// we will not be in the middle of a rebase operation.
// That's okay -- assume that they still want to abort
// the gs operation they were running.
} else {
wasRebasing = true
if err := repo.RebaseAbort(ctx); err != nil {
return fmt.Errorf("abort rebase: %w", err)
}
}
cont, err := store.TakeContinuation(ctx, "gs rebase abort")
if err != nil {
return fmt.Errorf("take rebase continuation: %w", err)
}
if cont == nil && !wasRebasing {
return errors.New("no operation to abort")
}
if cont != nil {
log.Debugf("%v: dropping continuation: %q", cont.Branch, cont.Command)
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"fmt"
"github.com/alecthomas/kong"
"github.com/charmbracelet/log"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/text"
)
type rebaseContinueCmd struct{}
func (*rebaseContinueCmd) Help() string {
return text.Dedent(`
This command continues an ongoing git-spice operation that was
interrupted by a Git rebase action.
Without an ongoing git-spice operation,
this is equivalent to 'git rebase --continue'.
For example, if 'gs upstack restack' encounters a conflict,
resolve the conflict and run 'gs rebase continue'
(or its shorthand 'gs rbc') to continue the operation.
`)
}
func (cmd *rebaseContinueCmd) Run(
ctx context.Context,
log *log.Logger,
opts *globalOptions,
parser *kong.Kong,
) error {
repo, err := git.Open(ctx, ".", git.OpenOptions{
Log: log,
})
if err != nil {
return fmt.Errorf("open repository: %w", err)
}
store, err := ensureStore(ctx, repo, log, opts)
if err != nil {
return err
}
svc := spice.NewService(repo, store, log)
var wasRebasing bool
if _, err := repo.RebaseState(ctx); err != nil {
if !errors.Is(err, git.ErrNoRebase) {
return fmt.Errorf("get rebase state: %w", err)
}
// If the user ran 'git rebase --continue' instead,
// we will not be in the middle of a rebase operation.
// That's okay -- assume that they still want to continue
// with the gs operations they were running.
} else {
// If we're in the middle of a rebase, finish it.
wasRebasing = true
if err := repo.RebaseContinue(ctx); err != nil {
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: err,
})
}
}
cont, err := store.TakeContinuation(ctx, "gs rebase continue")
if err != nil {
return fmt.Errorf("take rebase continuation: %w", err)
}
if cont == nil && !wasRebasing {
return errors.New("no operation to continue")
}
for cont != nil {
log.Debugf("Got rebase continuation: %q", cont.Command)
if err := repo.Checkout(ctx, cont.Branch); err != nil {
return fmt.Errorf("checkout branch %q: %w", cont.Branch, err)
}
kctx, err := parser.Parse(cont.Command)
if err != nil {
log.Errorf("Corrupt rebase continuation: %q", cont.Command)
return fmt.Errorf("parse rebase continuation: %w", err)
}
if err := kctx.Run(ctx); err != nil {
return fmt.Errorf("continue operation %q: %w", cont.Command, err)
}
cont, err = store.TakeContinuation(ctx, "gs rebase continue")
if err != nil {
return fmt.Errorf("take rebase continuation: %w", err)
}
}
return nil
}
+2
View File
@@ -31,6 +31,8 @@ func TestMain(m *testing.M) {
return 0
},
"mockedit": mockedit.Main,
// "true" is a no-op command that always succeeds.
"true": func() int { return 0 },
// with-term file -- cmd [args ...]
//
// Runs the given command inside a terminal emulator,
+10
View File
@@ -45,7 +45,17 @@ loop:
res, err := svc.Restack(ctx, branch)
if err != nil {
var rebaseErr *git.RebaseInterruptError
switch {
case errors.As(err, &rebaseErr):
// If the rebase is interrupted by a conflict,
// we'll resume by re-running this command.
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: rebaseErr,
Command: []string{"stack", "restack"},
Branch: currentBranch,
Message: fmt.Sprintf("interrupted: restack stack for %s", branch),
})
case errors.Is(err, spice.ErrAlreadyRestacked):
// Log the "does not need to be restacked" message
// only for branches that are not the current branch.
+66
View File
@@ -0,0 +1,66 @@
# 'branch edit' with a 'break' instruction
# can continue work afterwards with a 'rebase continue'.
as 'Test <test@example.com>'
at '2024-05-27T13:57:09Z'
cd repo
git init
git commit --allow-empty -m 'Initial commit'
gs repo init
git add feature1.txt
gs bc -m 'Add feature 1' feature1
git add feature2.txt
gs bc -m 'Add feature 2' feature2
git add feature3.txt
gs bc -m 'Add feature 3' feature3
gs bottom
# Run a 'gs branch edit', and add a 'break' instruction
# at the top of the rebase TODO.
env EDITOR=mockedit MOCKEDIT_GIVE=$WORK/input/rebase-todo.txt
! gs branch edit
stderr 'The rebase operation was interrupted'
stderr ' gs rebase continue'
stderr ' gs rebase abort'
# Add a new commit.
git add feature1-part2.txt
gs cc -m 'Add part 2 of feature 1'
gs rebase continue
stderr 'feature2: restacked'
stderr 'feature3: restacked'
# current branch should be back to feature1
git branch --show-current
stdout 'feature1'
git graph --branches
cmp stdout $WORK/golden/branches.txt
-- repo/feature1.txt --
Contents of feature 1.
-- repo/feature2.txt --
Contents of feature 2.
-- repo/feature3.txt --
Contents of feature 3.
-- repo/feature1-part2.txt --
Part 2 of feature 1.
-- input/rebase-todo.txt --
edit 3972713 Add feature 1
-- golden/branches.txt --
* c7c7547 (feature3) Add feature 3
* f5c4ede (feature2) Add feature 2
* aaf0fbd (HEAD -> feature1) Add part 2 of feature 1
* 3972713 Add feature 1
* a798a87 (main) Initial commit
+71
View File
@@ -0,0 +1,71 @@
# Changing the base for a branch with 'branch onto'
# while resolving a conflict.
as 'Test <test@example.com>'
at '2024-05-27T16:58:12Z'
# set up
mkdir repo
cd repo
git init
git commit --allow-empty -m 'Initial commit'
gs repo init
cp $WORK/extra/feature.1.txt feature.txt
git add feature.txt
gs bc A -m 'Add a feature'
cp $WORK/extra/feature.2.txt feature.txt
git add feature.txt
gs bc B -m 'Make a change'
cp $WORK/extra/feature.3.txt feature.txt
git add feature.txt
gs bc C -m 'Make a dependent change'
# At this point, we have:
# A --> B --> C --> D
# We'll attempt to move C onto A to get:
# A --> {B, C --> D}
# But that'll conflict and we'll have to resolve it.
! gs branch onto A
stderr 'There was a conflict while rebasing'
stderr ' gs rebase continue'
stderr ' gs rebase abort'
# Resolve the conflict
cp $WORK/extra/feature.3-resolved.txt feature.txt
git add feature.txt
# Continue the rebase without editing the commit message
env EDITOR=true
gs rebase continue
# Verify state
git graph --branches
cmp stdout $WORK/golden/graph.txt
cmp feature.txt $WORK/extra/feature.3-resolved.txt
-- extra/feature.1.txt --
Add a feature
-- extra/feature.2.txt --
Add a feature
Make a change
-- extra/feature.3.txt --
Add a feature
Make a dependent change
Make a change
-- extra/feature.3-resolved.txt --
Add a feature
Make a dependent change
-- golden/graph.txt --
* 7321210 (B) Make a change
| * 814d06b (HEAD -> C) Make a dependent change
|/
* 5ada401 (A) Add a feature
* 3cc7bfc (main) Initial commit
+60
View File
@@ -0,0 +1,60 @@
# 'branch restack' can continue from a conflict with 'gs rebase continue'
as 'Test <test@example.com>'
at '2024-05-27T18:24:42Z'
mkdir repo
cd repo
git init
git add init.txt
git commit -m 'Initial commit'
gs repo init
# create a feature branch that modifies init.
cp $WORK/extra/init.feature.txt init.txt
git add init.txt
gs bc -m feature
# go back to main and modify init
gs trunk
cp $WORK/extra/init.new.txt init.txt
git add init.txt
git commit -m 'Change init'
gs up
stderr 'feature: needs to be restacked'
# restack the feature branch
! gs branch restack
stderr 'There was a conflict while rebasing'
# resolve the conflict
cp $WORK/extra/init.resolved.txt init.txt
git add init.txt
env EDITOR=true
gs rebase continue
# verify state
cmp init.txt $WORK/extra/init.resolved.txt
git graph --branches
cmp stdout $WORK/golden/graph.txt
gs trunk
cmp init.txt $WORK/extra/init.new.txt
-- repo/init.txt --
initial init
-- extra/init.new.txt --
changed init
-- extra/init.feature.txt --
feature's init
-- extra/init.resolved.txt --
updated init
-- golden/graph.txt --
* bd2299a (HEAD -> feature) feature
* 57ab3b0 (main) Change init
* d692027 Initial commit
+51
View File
@@ -0,0 +1,51 @@
# 'branch restack' can cancel the restack with 'gs rebase abort'.
as 'Test <test@example.com>'
at '2024-05-27T18:24:42Z'
mkdir repo
cd repo
git init
git add init.txt
git commit -m 'Initial commit'
gs repo init
# create a feature branch that modifies init.
cp $WORK/extra/init.feature.txt init.txt
git add init.txt
gs bc -m feature
# go back to main and modify init
gs trunk
cp $WORK/extra/init.new.txt init.txt
git add init.txt
git commit -m 'Change init'
gs up
stderr 'feature: needs to be restacked'
# restack the feature branch
! gs branch restack
stderr 'There was a conflict while rebasing'
gs rebase abort
# verify state
git graph --branches
cmp stdout $WORK/golden/graph.txt
cmp init.txt $WORK/extra/init.feature.txt
-- repo/init.txt --
initial init
-- extra/init.new.txt --
changed init
-- extra/init.feature.txt --
feature's init
-- golden/graph.txt --
* 10ab8d9 (HEAD -> feature) feature
| * 57ab3b0 (main) Change init
|/
* d692027 Initial commit
+99
View File
@@ -0,0 +1,99 @@
# A 'stack restack' where a downstack and an upstack branch have conflicts.
as 'Test <test@example.com>'
at '2024-05-27T18:39:40Z'
mkdir repo
cd repo
git init
git commit --allow-empty -m 'Initial commit'
gs repo init
cp $WORK/extra/feature1.txt feature1.txt
git add feature1.txt
gs bc -m feature1
cp $WORK/extra/feature2.txt feature2.txt
git add feature2.txt
gs bc -m feature2
cp $WORK/extra/feature3.txt feature3.txt
git add feature3.txt
gs bc -m feature3
# go to main, add a file conflicting with feature1 and 3.
gs trunk
cp $WORK/extra/feature1.conflict.txt feature1.txt
cp $WORK/extra/feature3.conflict.txt feature3.txt
git add feature1.txt feature3.txt
git commit -m 'Ad feature 1 and 3 here for some reason'
env EDITOR=true
# go back to feature2 and try to restack
gs branch checkout feature2
! gs stack restack
stderr 'There was a conflict while rebasing'
stderr ' gs rebase continue'
stderr ' gs rebase abort'
# only feature1.txt should be conflicting right now
git status --porcelain
cmp stdout $WORK/golden/conflict-status-feature1.txt
# resolve the conflict and continue
cp $WORK/extra/feature1.resolved.txt feature1.txt
git add feature1.txt
! gs rebase continue
stderr 'There was a conflict while rebasing'
# only feature3.txt should be conflicting right now
git status --porcelain
cmp stdout $WORK/golden/conflict-status-feature3.txt
# resolve the conflict and continue
cp $WORK/extra/feature3.resolved.txt feature3.txt
git add feature3.txt
gs rebase continue
# the rebase should have succeeded
git graph --branches
cmp stdout $WORK/golden/graph.txt
# verify files
gs branch checkout feature3
cmp feature1.txt $WORK/extra/feature1.resolved.txt
cmp feature2.txt $WORK/extra/feature2.txt
cmp feature3.txt $WORK/extra/feature3.resolved.txt
-- extra/feature1.txt --
foo
-- extra/feature2.txt --
bar
-- extra/feature3.txt --
baz
-- extra/feature1.conflict.txt --
not foo
-- extra/feature3.conflict.txt --
not baz
-- extra/feature1.resolved.txt --
foo
not foo
-- extra/feature3.resolved.txt --
baz
not baz
-- golden/conflict-status-feature1.txt --
AA feature1.txt
-- golden/conflict-status-feature3.txt --
AA feature3.txt
-- golden/graph.txt --
* 00e57c1 (feature3) feature3
* f433bb9 (HEAD -> feature2) feature2
* d231589 (feature1) feature1
* e2b76d8 (main) Ad feature 1 and 3 here for some reason
* a545001 Initial commit
+99
View File
@@ -0,0 +1,99 @@
# An 'upstack restack' where the upstack branches have mutliple conflicts
# with the updated changes.
as 'Test <test@example.com>'
at '2024-05-27T18:39:40Z'
mkdir repo
cd repo
git init
git commit --allow-empty -m 'Initial commit'
gs repo init
cp $WORK/extra/feature1.txt feature1.txt
git add feature1.txt
gs bc -m feature1
cp $WORK/extra/feature2.txt feature2.txt
git add feature2.txt
gs bc -m feature2
cp $WORK/extra/feature3.txt feature3.txt
git add feature3.txt
gs bc -m feature3
# go back to feature1
# and add conflicting feature2 and feature3 files.
gs bottom
cp $WORK/extra/feature2.conflict.txt feature2.txt
cp $WORK/extra/feature3.conflict.txt feature3.txt
git add feature2.txt feature3.txt
! gs cc -m 'Add feature 2 and 3 here for some reason'
stderr 'There was a conflict while rebasing'
stderr ' gs rebase continue'
stderr ' gs rebase abort'
# only feature2.txt should be conflicting right now
git status --porcelain
cmp stdout $WORK/golden/conflict-status-feature2.txt
env EDITOR=true
# resolve the conflict and continue
cp $WORK/extra/feature2.resolved.txt feature2.txt
git add feature2.txt
! gs rebase continue
stderr 'There was a conflict while rebasing'
stderr ' gs rebase continue'
stderr ' gs rebase abort'
# only feature3.txt should be conflicting right now
git status --porcelain
cmp stdout $WORK/golden/conflict-status-feature3.txt
# resolve the conflict and continue
cp $WORK/extra/feature3.resolved.txt feature3.txt
git add feature3.txt
gs rebase continue
# the rebase should be complete now
git graph --branches
cmp stdout $WORK/golden/graph.txt
# verify files
gs branch checkout feature3
cmp feature1.txt $WORK/extra/feature1.txt
cmp feature2.txt $WORK/extra/feature2.resolved.txt
cmp feature3.txt $WORK/extra/feature3.resolved.txt
-- extra/feature1.txt --
foo
-- extra/feature2.txt --
bar
-- extra/feature3.txt --
baz
-- extra/feature2.conflict.txt --
not bar
-- extra/feature3.conflict.txt --
not baz
-- extra/feature2.resolved.txt --
bar
not bar
-- extra/feature3.resolved.txt --
baz
not baz
-- golden/conflict-status-feature2.txt --
AA feature2.txt
-- golden/conflict-status-feature3.txt --
AA feature3.txt
-- golden/graph.txt --
* 572dca3 (feature3) feature3
* 5de761e (feature2) feature2
* cbe5048 (HEAD -> feature1) Add feature 2 and 3 here for some reason
* d0f66a5 feature1
* a545001 (main) Initial commit
+10
View File
@@ -59,7 +59,17 @@ loop:
res, err := svc.Restack(ctx, upstack)
if err != nil {
var rebaseErr *git.RebaseInterruptError
switch {
case errors.As(err, &rebaseErr):
// If the rebase is interrupted by a conflict,
// we'll resume by re-running this command.
return svc.RebaseRescue(ctx, spice.RebaseRescueRequest{
Err: rebaseErr,
Command: []string{"upstack", "restack"},
Branch: currentBranch,
Message: fmt.Sprintf("interrupted: restack upstack of %v", currentBranch),
})
case errors.Is(err, spice.ErrAlreadyRestacked):
// Log the "does not need to be restacked" message
// only for branches that are not the current branch.