Files
github__gh-stack/internal/git/mock_ops.go
Sameen Karim 0c51c08255 Fix to properly revert after aborting modify (#167)
* Add cherry-pick abort/quit/in-progress git primitives

Introduce IsCherryPickInProgress() (detects .git/CHERRY_PICK_HEAD) and split
the existing cherry-pick reset into two distinct operations:

- CherryPickQuit() runs `git cherry-pick --quit`, clearing the sequencer
  state without touching the index (used to clear stale state before starting
  a fresh cherry-pick).
- CherryPickAbort() now runs `git cherry-pick --abort`, which fully restores
  the working tree and index to the pre-cherry-pick state.

The previous CherryPickAbort() ran --quit, which leaves an unmerged index and
therefore cannot recover a conflicted fold-down. Integration tests cover the
in-progress detection, the full abort restore, and the --quit-leaves-index
behavior.

* Fix modify --abort leaving a broken stack after a conflict

When `gh stack modify` hit a rebase or cherry-pick conflict it saved state
with phase "conflict" and told the user to run `gh stack modify --abort` to
restore. But runModifyAbort had no case for PhaseConflict, so it fell into the
default branch that merely printed "unexpected modify state phase" and deleted
the state file without unwinding. The in-flight rebase/cherry-pick stayed
active, branches were left partially rewritten, and the deleted state file also
made --continue impossible: the stack was stuck in limbo.

Fixes:

- runModifyAbort now unwinds on PhaseConflict (same recovery as PhaseApplying),
  aborting the in-progress operation, resetting branch tips to their pre-modify
  SHAs, restoring stack metadata, and clearing state.
- Unwind now also aborts an in-progress cherry-pick (fold-down conflicts), not
  just a rebase. Without this the restore checkouts would fail on the unmerged
  cherry-pick index.
- ContinueApply now records a subsequent cascade-rebase conflict as
  ConflictType "rebase" instead of leaving a stale "cherry_pick", so the next
  --continue calls RebaseContinue rather than failing in CherryPickContinue.

Adds coverage for the conflict-phase abort, pending-submit no-op abort, Unwind
aborting an active cherry-pick, and the cherry-pick to rebase ConflictType
transition.

* Persist fold-branch removal when a post-fold cascade rebase conflicts

ContinueApply removes the folded branch from the in-memory stack after a
fold-down cherry-pick is resolved, but a subsequent cascade rebase conflict
only saved the modify state file, not the stack metadata. On the next
--continue the on-disk metadata (folded branch still present) was re-read, and
because ConflictType is now "rebase" the fold-removal block was skipped, so the
final save resurrected the folded branch as a phantom entry pointing at an
orphaned tip.

Persist the stack file alongside the state file on a cascade-rebase conflict,
mirroring ApplyPlan's save-on-conflict, so the fold removal survives recovery.
Adds an end-to-end regression test covering the fold-then-cascade-conflict path
across two --continue calls.
2026-07-15 12:07:42 -04:00

453 lines
11 KiB
Go

package git
import "fmt"
// MockOps is a test double for git operations.
// Each field is an optional function that, when set, handles the corresponding
// Ops method call. When nil, a reasonable default is returned.
type MockOps struct {
GitDirFn func() (string, error)
RootDirFn func() (string, error)
CurrentBranchFn func() (string, error)
BranchExistsFn func(string) bool
CheckoutBranchFn func(string) error
FetchFn func(string) error
FetchBranchesFn func(string, []string) error
DefaultBranchFn func() (string, error)
CreateBranchFn func(string, string) error
PushFn func(string, []string, bool, bool) error
ResolveRemoteFn func(string) (string, error)
RebaseFn func(string, RebaseOpts) error
EnableRerereFn func() error
IsRerereEnabledFn func() (bool, error)
IsRerereDeclinedFn func() (bool, error)
SaveRerereDeclinedFn func() error
GetSavedRemoteFn func() (string, error)
SaveRemoteFn func(string) error
ClearRemoteFn func() error
RebaseOntoFn func(string, string, string, RebaseOpts) error
RebaseContinueFn func(RebaseOpts) error
RebaseAbortFn func() error
IsRebaseInProgressFn func() bool
ConflictedFilesFn func() ([]string, error)
FindConflictMarkersFn func(string) (*ConflictMarkerInfo, error)
IsAncestorFn func(string, string) (bool, error)
RevParseFn func(string) (string, error)
RevParseMultiFn func([]string) ([]string, error)
MergeBaseFn func(string, string) (string, error)
LogFn func(string, int) ([]CommitInfo, error)
LogRangeFn func(string, string) ([]CommitInfo, error)
DiffStatRangeFn func(string, string) (int, int, error)
DiffStatFilesFn func(string, string) ([]FileDiffStat, error)
DeleteBranchFn func(string, bool) error
DeleteRemoteBranchFn func(string, string) error
DeleteTrackingRefFn func(string, string) error
ResetHardFn func(string) error
SetUpstreamTrackingFn func(string, string) error
MergeFFFn func(string) error
UpdateBranchRefFn func(string, string) error
StageAllFn func() error
StageTrackedFn func() error
HasStagedChangesFn func() bool
CommitFn func(string) (string, error)
CommitInteractiveFn func() (string, error)
ValidateRefNameFn func(string) error
RenameBranchFn func(string, string) error
CherryPickFn func([]string) error
CherryPickQuitFn func() error
CherryPickAbortFn func() error
CherryPickContinueFn func() error
IsCherryPickInProgressFn func() bool
HasUncommittedChangesFn func() (bool, error)
LogMergesFn func(string, string) ([]CommitInfo, error)
}
var _ Ops = (*MockOps)(nil)
func (m *MockOps) GitDir() (string, error) {
if m.GitDirFn != nil {
return m.GitDirFn()
}
return "/tmp/fake-git-dir", nil
}
func (m *MockOps) RootDir() (string, error) {
if m.RootDirFn != nil {
return m.RootDirFn()
}
return "/tmp/fake-repo", nil
}
func (m *MockOps) CurrentBranch() (string, error) {
if m.CurrentBranchFn != nil {
return m.CurrentBranchFn()
}
return "main", nil
}
func (m *MockOps) BranchExists(name string) bool {
if m.BranchExistsFn != nil {
return m.BranchExistsFn(name)
}
return false
}
func (m *MockOps) CheckoutBranch(name string) error {
if m.CheckoutBranchFn != nil {
return m.CheckoutBranchFn(name)
}
return nil
}
func (m *MockOps) Fetch(remote string) error {
if m.FetchFn != nil {
return m.FetchFn(remote)
}
return nil
}
func (m *MockOps) FetchBranches(remote string, branches []string) error {
if m.FetchBranchesFn != nil {
return m.FetchBranchesFn(remote, branches)
}
return nil
}
func (m *MockOps) DefaultBranch() (string, error) {
if m.DefaultBranchFn != nil {
return m.DefaultBranchFn()
}
return "main", nil
}
func (m *MockOps) CreateBranch(name, base string) error {
if m.CreateBranchFn != nil {
return m.CreateBranchFn(name, base)
}
return nil
}
func (m *MockOps) Push(remote string, branches []string, force, atomic bool) error {
if m.PushFn != nil {
return m.PushFn(remote, branches, force, atomic)
}
return nil
}
func (m *MockOps) ResolveRemote(branch string) (string, error) {
if m.ResolveRemoteFn != nil {
return m.ResolveRemoteFn(branch)
}
return "origin", nil
}
func (m *MockOps) Rebase(base string, opts RebaseOpts) error {
if m.RebaseFn != nil {
return m.RebaseFn(base, opts)
}
return nil
}
func (m *MockOps) EnableRerere() error {
if m.EnableRerereFn != nil {
return m.EnableRerereFn()
}
return nil
}
func (m *MockOps) IsRerereEnabled() (bool, error) {
if m.IsRerereEnabledFn != nil {
return m.IsRerereEnabledFn()
}
return false, nil
}
func (m *MockOps) IsRerereDeclined() (bool, error) {
if m.IsRerereDeclinedFn != nil {
return m.IsRerereDeclinedFn()
}
return false, nil
}
func (m *MockOps) SaveRerereDeclined() error {
if m.SaveRerereDeclinedFn != nil {
return m.SaveRerereDeclinedFn()
}
return nil
}
func (m *MockOps) GetSavedRemote() (string, error) {
if m.GetSavedRemoteFn != nil {
return m.GetSavedRemoteFn()
}
return "", fmt.Errorf("not set")
}
func (m *MockOps) SaveRemote(remote string) error {
if m.SaveRemoteFn != nil {
return m.SaveRemoteFn(remote)
}
return nil
}
func (m *MockOps) ClearRemote() error {
if m.ClearRemoteFn != nil {
return m.ClearRemoteFn()
}
return nil
}
func (m *MockOps) RebaseOnto(newBase, oldBase, branch string, opts RebaseOpts) error {
if m.RebaseOntoFn != nil {
return m.RebaseOntoFn(newBase, oldBase, branch, opts)
}
return nil
}
func (m *MockOps) RebaseContinue(opts RebaseOpts) error {
if m.RebaseContinueFn != nil {
return m.RebaseContinueFn(opts)
}
return nil
}
func (m *MockOps) RebaseAbort() error {
if m.RebaseAbortFn != nil {
return m.RebaseAbortFn()
}
return nil
}
func (m *MockOps) IsRebaseInProgress() bool {
if m.IsRebaseInProgressFn != nil {
return m.IsRebaseInProgressFn()
}
return false
}
func (m *MockOps) ConflictedFiles() ([]string, error) {
if m.ConflictedFilesFn != nil {
return m.ConflictedFilesFn()
}
return nil, nil
}
func (m *MockOps) FindConflictMarkers(filePath string) (*ConflictMarkerInfo, error) {
if m.FindConflictMarkersFn != nil {
return m.FindConflictMarkersFn(filePath)
}
return nil, nil
}
func (m *MockOps) IsAncestor(ancestor, descendant string) (bool, error) {
if m.IsAncestorFn != nil {
return m.IsAncestorFn(ancestor, descendant)
}
return false, nil
}
func (m *MockOps) RevParse(ref string) (string, error) {
if m.RevParseFn != nil {
return m.RevParseFn(ref)
}
return "", nil
}
func (m *MockOps) RevParseMulti(refs []string) ([]string, error) {
if m.RevParseMultiFn != nil {
return m.RevParseMultiFn(refs)
}
// Default: delegate to RevParse for each ref.
shas := make([]string, len(refs))
for i, ref := range refs {
sha, err := m.RevParse(ref)
if err != nil {
return nil, err
}
shas[i] = sha
}
return shas, nil
}
func (m *MockOps) MergeBase(a, b string) (string, error) {
if m.MergeBaseFn != nil {
return m.MergeBaseFn(a, b)
}
return "", nil
}
func (m *MockOps) Log(ref string, maxCount int) ([]CommitInfo, error) {
if m.LogFn != nil {
return m.LogFn(ref, maxCount)
}
return nil, nil
}
func (m *MockOps) LogRange(base, head string) ([]CommitInfo, error) {
if m.LogRangeFn != nil {
return m.LogRangeFn(base, head)
}
return nil, nil
}
func (m *MockOps) DiffStatRange(base, head string) (int, int, error) {
if m.DiffStatRangeFn != nil {
return m.DiffStatRangeFn(base, head)
}
return 0, 0, nil
}
func (m *MockOps) DiffStatFiles(base, head string) ([]FileDiffStat, error) {
if m.DiffStatFilesFn != nil {
return m.DiffStatFilesFn(base, head)
}
return nil, nil
}
func (m *MockOps) DeleteBranch(name string, force bool) error {
if m.DeleteBranchFn != nil {
return m.DeleteBranchFn(name, force)
}
return nil
}
func (m *MockOps) DeleteRemoteBranch(remote, branch string) error {
if m.DeleteRemoteBranchFn != nil {
return m.DeleteRemoteBranchFn(remote, branch)
}
return nil
}
func (m *MockOps) DeleteTrackingRef(remote, branch string) error {
if m.DeleteTrackingRefFn != nil {
return m.DeleteTrackingRefFn(remote, branch)
}
return nil
}
func (m *MockOps) ResetHard(ref string) error {
if m.ResetHardFn != nil {
return m.ResetHardFn(ref)
}
return nil
}
func (m *MockOps) SetUpstreamTracking(branch, remote string) error {
if m.SetUpstreamTrackingFn != nil {
return m.SetUpstreamTrackingFn(branch, remote)
}
return nil
}
func (m *MockOps) MergeFF(target string) error {
if m.MergeFFFn != nil {
return m.MergeFFFn(target)
}
return nil
}
func (m *MockOps) UpdateBranchRef(branch, sha string) error {
if m.UpdateBranchRefFn != nil {
return m.UpdateBranchRefFn(branch, sha)
}
return nil
}
func (m *MockOps) StageAll() error {
if m.StageAllFn != nil {
return m.StageAllFn()
}
return nil
}
func (m *MockOps) StageTracked() error {
if m.StageTrackedFn != nil {
return m.StageTrackedFn()
}
return nil
}
func (m *MockOps) HasStagedChanges() bool {
if m.HasStagedChangesFn != nil {
return m.HasStagedChangesFn()
}
return false
}
func (m *MockOps) Commit(message string) (string, error) {
if m.CommitFn != nil {
return m.CommitFn(message)
}
return "", nil
}
func (m *MockOps) CommitInteractive() (string, error) {
if m.CommitInteractiveFn != nil {
return m.CommitInteractiveFn()
}
return "", nil
}
func (m *MockOps) ValidateRefName(name string) error {
if m.ValidateRefNameFn != nil {
return m.ValidateRefNameFn(name)
}
return nil
}
func (m *MockOps) RenameBranch(oldName, newName string) error {
if m.RenameBranchFn != nil {
return m.RenameBranchFn(oldName, newName)
}
return nil
}
func (m *MockOps) CherryPick(commits []string) error {
if m.CherryPickFn != nil {
return m.CherryPickFn(commits)
}
return nil
}
func (m *MockOps) CherryPickQuit() error {
if m.CherryPickQuitFn != nil {
return m.CherryPickQuitFn()
}
return nil
}
func (m *MockOps) CherryPickAbort() error {
if m.CherryPickAbortFn != nil {
return m.CherryPickAbortFn()
}
return nil
}
func (m *MockOps) IsCherryPickInProgress() bool {
if m.IsCherryPickInProgressFn != nil {
return m.IsCherryPickInProgressFn()
}
return false
}
func (m *MockOps) CherryPickContinue() error {
if m.CherryPickContinueFn != nil {
return m.CherryPickContinueFn()
}
return nil
}
func (m *MockOps) HasUncommittedChanges() (bool, error) {
if m.HasUncommittedChangesFn != nil {
return m.HasUncommittedChangesFn()
}
return false, nil
}
func (m *MockOps) LogMerges(base, head string) ([]CommitInfo, error) {
if m.LogMergesFn != nil {
return m.LogMergesFn(base, head)
}
return nil, nil
}