mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
0c51c08255
* 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.
453 lines
11 KiB
Go
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
|
|
}
|