mirror of
https://github.com/abhinav/git-spice.git
synced 2026-08-31 07:47:47 +02:00
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:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user