mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
7a268fc380
* git primitives for modify cmd * extract reusable TUI parts * modify cmd * recreate stack after modify * add checks to prevent other modifications while modify is applying * modify continue for resuming after resolving conflicts * fix bug with duplicate stack entries after modifying * reuse conflict resolution help msg from rebase * additional confirmation before overwriting stack on remote * fix recreate order of operations Co-authored-by: Copilot <copilot@github.com> * move base commit instead of cherry picking for fold up * check to ensure we aren't left with zero branches * unify and dedupe across view and modify tui * more detailed help instructions Co-authored-by: Copilot <copilot@github.com> * only recommend submit if stack exists on remote Co-authored-by: Copilot <copilot@github.com> * tests for modify tui, apply modifications, submit modifications * refactor submit for regular and pending modifications * rename recover to abort Co-authored-by: Copilot <copilot@github.com> * docs for modify cmd * tui styling updates * updated tui screenshot * addressing review comments * Fix 4 bugs from code review Bug 1: Move RevParseMap error check before using originalRefs. The error from git.RevParseMap() was deferred past iteration of originalRefs, which could panic on a nil map. Bug 2: Differentiate cherry-pick vs rebase conflicts in modify. Cherry-pick conflicts don't save state as 'conflict' phase, so --continue won't work. Now prints --abort-only instructions for cherry-pick conflicts. Bug 3: Unwind now cleans up branches created by renames. After restoring snapshot branches, Unwind deletes renamed branch names that don't belong to the original snapshot. Bug 4: Simplify push message in submit command. Changed from 'Pushing N branches to remote...' to 'Pushing to remote...' since individual branches may fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix 7 nit issues from code review 11: Add named constants for phase strings (PhaseApplying, PhaseConflict, PhasePendingSubmit) in state.go; replace remaining raw literals in state.go CheckStateGuard. 14: Fix bottomLines comment mismatch — listed 3 items but value is 2. 15: Extract magic number 88 to MinWidthForArt constant in header.go. 16: Remove unused stackview import anchor in model.go — the import is used via types.go where BranchNode is embedded. 17: Simplify CheckStackLinearity parent resolution — ActiveBaseBranch already handles skipping merged branches. 18: Fix rename undo matching any rename — add NewName check so only the specific rename being undone is matched. 20: Add TestUndoRename and TestUndoRename_DoesNotAffectOtherRenames to validate rename undo behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make cherry-pick conflicts recoverable via --continue Previously, cherry-pick conflicts during fold-down operations could only be resolved with --abort. Now they save full conflict state (phase, conflict type, fold branch/target, remaining branches) to the state file, enabling recovery via 'gh stack modify --continue'. Changes: - Add ConflictType field to StateFile (rebase or cherry_pick) - Add FoldBranch/FoldTarget fields for cherry-pick context - Add CherryPickContinue to git package (cherry-pick --continue) - Save cherry-pick conflict state in ApplyPlan with remaining branches - ContinueApply handles both rebase and cherry-pick conflicts - Unified conflict messaging in cmd/modify.go (both types show --continue) - Updated test to verify cherry-pick conflict state is saved correctly * Apply suggestions from code review Co-authored-by: Luke Ghenco <lukeghenco@github.com> Co-authored-by: Sameen Karim <skarim@github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Luke Ghenco <lukeghenco@github.com>
779 lines
23 KiB
Go
779 lines
23 KiB
Go
package modify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/github/gh-stack/internal/config"
|
|
"github.com/github/gh-stack/internal/git"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
"github.com/github/gh-stack/internal/tui/modifyview"
|
|
)
|
|
|
|
// BuildSnapshot captures the current state of the stack for unwind/recovery.
|
|
func BuildSnapshot(s *stack.Stack) (Snapshot, error) {
|
|
// Collect all branch names
|
|
names := make([]string, len(s.Branches))
|
|
for i, b := range s.Branches {
|
|
names[i] = b.Branch
|
|
}
|
|
|
|
// Resolve all SHAs
|
|
shaMap, err := git.RevParseMap(names)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("resolving branch SHAs: %w", err)
|
|
}
|
|
|
|
// Build branch snapshots
|
|
branches := make([]BranchSnapshot, len(s.Branches))
|
|
for i, b := range s.Branches {
|
|
branches[i] = BranchSnapshot{
|
|
Name: b.Branch,
|
|
TipSHA: shaMap[b.Branch],
|
|
Position: i,
|
|
}
|
|
}
|
|
|
|
// Serialize stack metadata
|
|
stackJSON, err := json.Marshal(s)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("serializing stack metadata: %w", err)
|
|
}
|
|
|
|
return Snapshot{
|
|
Branches: branches,
|
|
StackMetadata: stackJSON,
|
|
}, nil
|
|
}
|
|
|
|
// BuildPlan converts the TUI's staged actions into a list of Actions
|
|
// suitable for storage in the state file.
|
|
func BuildPlan(nodes []modifyview.ModifyBranchNode) []Action {
|
|
var plan []Action
|
|
|
|
for i, n := range nodes {
|
|
if n.PendingAction == nil && n.OriginalPosition == i && !n.Removed {
|
|
continue
|
|
}
|
|
|
|
if n.Removed {
|
|
continue // Removed nodes are handled by their pending action
|
|
}
|
|
|
|
if n.PendingAction != nil {
|
|
action := Action{
|
|
Type: string(n.PendingAction.Type),
|
|
Branch: n.Ref.Branch,
|
|
}
|
|
if n.PendingAction.Type == modifyview.ActionRename {
|
|
action.NewName = n.PendingAction.NewName
|
|
}
|
|
plan = append(plan, action)
|
|
}
|
|
|
|
if n.OriginalPosition != i && n.PendingAction == nil {
|
|
plan = append(plan, Action{
|
|
Type: "move",
|
|
Branch: n.Ref.Branch,
|
|
NewPosition: i,
|
|
})
|
|
}
|
|
}
|
|
|
|
return plan
|
|
}
|
|
|
|
// ApplyPlan executes the staged modifications on the stack.
|
|
// updateBaseSHAs is called after rebasing to refresh branch SHAs in the stack metadata.
|
|
// It returns an ApplyResult on success or a ConflictInfo if a rebase conflict occurs.
|
|
func ApplyPlan(
|
|
cfg *config.Config,
|
|
gitDir string,
|
|
s *stack.Stack,
|
|
sf *stack.StackFile,
|
|
nodes []modifyview.ModifyBranchNode,
|
|
currentBranch string,
|
|
updateBaseSHAs func(*stack.Stack),
|
|
) (*modifyview.ApplyResult, *modifyview.ConflictInfo, error) {
|
|
// Build the snapshot before any changes
|
|
snapshot, err := BuildSnapshot(s)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("building snapshot: %w", err)
|
|
}
|
|
|
|
// Acquire the stack lock before making any changes
|
|
lock, err := stack.Lock(gitDir)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("acquiring stack lock: %w", err)
|
|
}
|
|
defer lock.Unlock()
|
|
|
|
plan := BuildPlan(nodes)
|
|
|
|
// Find the index of this stack in the stack file for reliable identification
|
|
stackIndex := -1
|
|
for i := range sf.Stacks {
|
|
if &sf.Stacks[i] == s {
|
|
stackIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Write state file with phase "applying"
|
|
stateFile := &StateFile{
|
|
SchemaVersion: 1,
|
|
StackName: s.Trunk.Branch,
|
|
StackIndex: stackIndex,
|
|
StartedAt: time.Now().UTC(),
|
|
Phase: PhaseApplying,
|
|
PriorRemoteStackID: s.ID,
|
|
Snapshot: snapshot,
|
|
Plan: plan,
|
|
}
|
|
if err := SaveState(gitDir, stateFile); err != nil {
|
|
return nil, nil, fmt.Errorf("saving modify state: %w", err)
|
|
}
|
|
|
|
result := &modifyview.ApplyResult{Success: true}
|
|
|
|
// Collect original refs for rebase --onto, including trunk
|
|
branchNames := make([]string, 0, len(s.Branches)+1)
|
|
branchNames = append(branchNames, s.Trunk.Branch)
|
|
for _, b := range s.Branches {
|
|
if !b.IsMerged() && git.BranchExists(b.Branch) {
|
|
branchNames = append(branchNames, b.Branch)
|
|
}
|
|
}
|
|
originalRefs, err := git.RevParseMap(branchNames)
|
|
if err != nil {
|
|
// Unwind on failure
|
|
unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan)
|
|
if unwindErr != nil {
|
|
return nil, nil, fmt.Errorf("failed to resolve refs (%v) and unwind failed (%v)", err, unwindErr)
|
|
}
|
|
return nil, nil, fmt.Errorf("failed to resolve branch SHAs: %w", err)
|
|
}
|
|
|
|
// Build a map of each branch's original parent tip SHA for accurate --onto rebase
|
|
originalParentTips := make(map[string]string)
|
|
for i, b := range s.Branches {
|
|
if b.IsMerged() {
|
|
continue
|
|
}
|
|
var parentName string
|
|
if i == 0 {
|
|
parentName = s.Trunk.Branch
|
|
} else {
|
|
parentName = s.ActiveBaseBranch(b.Branch)
|
|
}
|
|
if sha, ok := originalRefs[parentName]; ok {
|
|
originalParentTips[b.Branch] = sha
|
|
}
|
|
}
|
|
|
|
// Step 1: Renames
|
|
for i, n := range nodes {
|
|
if n.PendingAction != nil && n.PendingAction.Type == modifyview.ActionRename {
|
|
oldName := n.Ref.Branch
|
|
newName := n.PendingAction.NewName
|
|
if err := git.RenameBranch(oldName, newName); err != nil {
|
|
unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan)
|
|
if unwindErr != nil {
|
|
return nil, nil, fmt.Errorf("rename failed (%v) and unwind failed (%v)", err, unwindErr)
|
|
}
|
|
return nil, nil, fmt.Errorf("renaming %s to %s: %w", oldName, newName, err)
|
|
}
|
|
|
|
// Update in-memory state
|
|
idx := s.IndexOf(oldName)
|
|
if idx >= 0 {
|
|
// Update originalRefs key
|
|
if sha, ok := originalRefs[oldName]; ok {
|
|
originalRefs[newName] = sha
|
|
delete(originalRefs, oldName)
|
|
}
|
|
// Update originalParentTips key
|
|
if sha, ok := originalParentTips[oldName]; ok {
|
|
originalParentTips[newName] = sha
|
|
delete(originalParentTips, oldName)
|
|
}
|
|
s.Branches[idx].Branch = newName
|
|
}
|
|
// Update the node's ref for later steps
|
|
nodes[i].Ref.Branch = newName
|
|
|
|
result.RenamedBranches = append(result.RenamedBranches, modifyview.RenamedBranch{
|
|
OldName: oldName,
|
|
NewName: newName,
|
|
})
|
|
cfg.Successf("Renamed %s → %s", oldName, newName)
|
|
}
|
|
}
|
|
|
|
// Step 2: Folds — absorb one branch's commits into an adjacent branch.
|
|
//
|
|
// Fold-down: cherry-pick the folded branch's commits onto the target below.
|
|
// The target is below in the stack (closer to trunk), so it doesn't
|
|
// contain the folded branch's commits. Cherry-pick adds them.
|
|
//
|
|
// Fold-up: the target (above) already contains the folded branch's commits
|
|
// in its ancestry (it's stacked on top). Instead of cherry-picking, we
|
|
// adjust originalParentTips so the cascading rebase replays both the
|
|
// folded branch's commits AND the target's own commits when rebasing
|
|
// the target onto the folded branch's base.
|
|
for _, n := range nodes {
|
|
if n.PendingAction == nil {
|
|
continue
|
|
}
|
|
if n.PendingAction.Type != modifyview.ActionFoldDown && n.PendingAction.Type != modifyview.ActionFoldUp {
|
|
continue
|
|
}
|
|
|
|
foldBranch := n.Ref.Branch
|
|
|
|
// Determine target branch
|
|
var targetBranch string
|
|
foldIdx := s.IndexOf(foldBranch)
|
|
if foldIdx < 0 {
|
|
continue
|
|
}
|
|
|
|
if n.PendingAction.Type == modifyview.ActionFoldDown {
|
|
// Target is the branch below (toward trunk)
|
|
if foldIdx == 0 {
|
|
continue
|
|
}
|
|
targetBranch = s.Branches[foldIdx-1].Branch
|
|
} else {
|
|
// Target is the branch above (away from trunk)
|
|
if foldIdx >= len(s.Branches)-1 {
|
|
continue
|
|
}
|
|
targetBranch = s.Branches[foldIdx+1].Branch
|
|
}
|
|
|
|
baseBranch := s.ActiveBaseBranch(foldBranch)
|
|
|
|
if n.PendingAction.Type == modifyview.ActionFoldDown {
|
|
// Fold-down: cherry-pick the folded branch's commits onto the target.
|
|
commits, err := git.LogRange(baseBranch, foldBranch)
|
|
if err != nil || len(commits) == 0 {
|
|
cfg.Printf("No commits to fold from %s", foldBranch)
|
|
} else {
|
|
if err := git.CheckoutBranch(targetBranch); err != nil {
|
|
unwindErr := Unwind(cfg, gitDir, snapshot, stackIndex, sf, plan)
|
|
if unwindErr != nil {
|
|
return nil, nil, fmt.Errorf("checkout failed (%v) and unwind failed (%v)", err, unwindErr)
|
|
}
|
|
return nil, nil, fmt.Errorf("checking out %s for fold: %w", targetBranch, err)
|
|
}
|
|
|
|
shas := make([]string, len(commits))
|
|
for i, c := range commits {
|
|
shas[len(commits)-1-i] = c.SHA
|
|
}
|
|
|
|
git.CherryPickAbort()
|
|
|
|
if err := git.CherryPick(shas); err != nil {
|
|
conflict := &modifyview.ConflictInfo{Branch: foldBranch}
|
|
if files, ferr := git.ConflictedFiles(); ferr == nil {
|
|
conflict.ConflictedFiles = files
|
|
}
|
|
|
|
// Compute remaining branches for cascading rebase after cherry-pick resumes.
|
|
// Since folds happen before cascading rebase (Step 5), all non-merged, non-folded
|
|
// branches need rebasing.
|
|
remaining := make([]string, 0)
|
|
for _, br := range s.Branches {
|
|
if !br.IsMerged() && br.Branch != foldBranch {
|
|
remaining = append(remaining, br.Branch)
|
|
}
|
|
}
|
|
|
|
// Save conflict state so --continue can resume the cherry-pick
|
|
stateFile.Phase = PhaseConflict
|
|
stateFile.ConflictBranch = foldBranch
|
|
stateFile.ConflictType = "cherry_pick"
|
|
stateFile.FoldBranch = foldBranch
|
|
stateFile.FoldTarget = targetBranch
|
|
stateFile.RemainingBranches = remaining
|
|
stateFile.OriginalBranch = currentBranch
|
|
stateFile.OriginalRefs = originalParentTips
|
|
if saveErr := SaveState(gitDir, stateFile); saveErr != nil {
|
|
cfg.Warningf("failed to save conflict state: %v", saveErr)
|
|
}
|
|
|
|
// Save stack metadata so far
|
|
if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil {
|
|
cfg.Warningf("failed to save stack metadata: %v", saveErr)
|
|
}
|
|
|
|
return nil, conflict, fmt.Errorf("cherry-pick conflict folding %s into %s", foldBranch, targetBranch)
|
|
}
|
|
|
|
cfg.Successf("Folded %s into %s (%d commits)", foldBranch, targetBranch, len(commits))
|
|
}
|
|
} else {
|
|
// Fold-up: the target (above) already has the folded branch's
|
|
// commits in its history. We adjust originalParentTips so the
|
|
// cascading rebase uses the folded branch's BASE as the cutoff,
|
|
// replaying both the folded branch's commits and the target's
|
|
// own commits onto the new parent.
|
|
originalParentTips[targetBranch] = originalParentTips[foldBranch]
|
|
cfg.Successf("Folded %s into %s", foldBranch, targetBranch)
|
|
}
|
|
|
|
// Remove folded branch from stack metadata
|
|
foldIdx = s.IndexOf(foldBranch) // re-resolve in case earlier folds shifted indices
|
|
if foldIdx >= 0 && foldIdx < len(s.Branches) {
|
|
s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...)
|
|
}
|
|
}
|
|
|
|
// Step 3: Drops — remove from stack metadata
|
|
// Process in reverse order to preserve indices
|
|
for i := len(nodes) - 1; i >= 0; i-- {
|
|
n := nodes[i]
|
|
if n.PendingAction == nil || n.PendingAction.Type != modifyview.ActionDrop {
|
|
continue
|
|
}
|
|
|
|
dropBranch := n.Ref.Branch
|
|
dropIdx := s.IndexOf(dropBranch)
|
|
if dropIdx < 0 {
|
|
continue
|
|
}
|
|
|
|
if n.Ref.PullRequest != nil && n.Ref.PullRequest.Number > 0 {
|
|
result.DroppedPRs = append(result.DroppedPRs, modifyview.DroppedPR{
|
|
Branch: dropBranch,
|
|
PRNumber: n.Ref.PullRequest.Number,
|
|
})
|
|
}
|
|
|
|
s.Branches = append(s.Branches[:dropIdx], s.Branches[dropIdx+1:]...)
|
|
cfg.Successf("Dropped %s from stack", dropBranch)
|
|
}
|
|
|
|
// Step 4: Reorder — build the desired branch order from the remaining nodes
|
|
desiredOrder := make([]string, 0)
|
|
for _, n := range nodes {
|
|
if n.Removed {
|
|
continue
|
|
}
|
|
if n.PendingAction != nil && (n.PendingAction.Type == modifyview.ActionDrop ||
|
|
n.PendingAction.Type == modifyview.ActionFoldDown ||
|
|
n.PendingAction.Type == modifyview.ActionFoldUp) {
|
|
continue
|
|
}
|
|
if n.Ref.IsMerged() {
|
|
continue // Merged branches keep their position
|
|
}
|
|
desiredOrder = append(desiredOrder, n.Ref.Branch)
|
|
}
|
|
|
|
// Check if reorder is needed by comparing with current stack order
|
|
currentOrder := make([]string, 0)
|
|
for _, b := range s.Branches {
|
|
if !b.IsMerged() {
|
|
currentOrder = append(currentOrder, b.Branch)
|
|
}
|
|
}
|
|
|
|
needsReorder := false
|
|
if len(desiredOrder) == len(currentOrder) {
|
|
for i := range desiredOrder {
|
|
if desiredOrder[i] != currentOrder[i] {
|
|
needsReorder = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
needsReorder = true
|
|
}
|
|
|
|
// Rebuild s.Branches in the desired order, preserving merged branches
|
|
// at their original positions.
|
|
if needsReorder {
|
|
// Build a queue of active branches in the desired order
|
|
desiredIdx := 0
|
|
branchMap := make(map[string]stack.BranchRef)
|
|
for _, b := range s.Branches {
|
|
branchMap[b.Branch] = b
|
|
}
|
|
|
|
newBranches := make([]stack.BranchRef, 0, len(s.Branches))
|
|
for _, b := range s.Branches {
|
|
if b.IsMerged() {
|
|
// Merged branches stay at their original position
|
|
newBranches = append(newBranches, b)
|
|
} else {
|
|
// Substitute the next active branch from the desired order
|
|
if desiredIdx < len(desiredOrder) {
|
|
if sub, ok := branchMap[desiredOrder[desiredIdx]]; ok {
|
|
newBranches = append(newBranches, sub)
|
|
}
|
|
desiredIdx++
|
|
}
|
|
}
|
|
}
|
|
|
|
s.Branches = newBranches
|
|
}
|
|
|
|
// Step 5: Cascading rebase — rebase each active branch onto its new parent.
|
|
// Use the original parent tip SHA as the oldBase for --onto, so that only
|
|
// the branch's own commits are replayed onto the new parent.
|
|
for i, b := range s.Branches {
|
|
if b.IsMerged() {
|
|
continue
|
|
}
|
|
|
|
var newBase string
|
|
if i == 0 {
|
|
newBase = s.Trunk.Branch
|
|
} else {
|
|
newBase = s.ActiveBaseBranch(b.Branch)
|
|
}
|
|
|
|
// Use the branch's original parent tip as the oldBase for --onto.
|
|
// This ensures we replay only this branch's unique commits.
|
|
oldBase, hasOldBase := originalParentTips[b.Branch]
|
|
if !hasOldBase {
|
|
// No original parent recorded — try merge-base as fallback
|
|
if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil {
|
|
oldBase = mb
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Check if rebase is actually needed
|
|
isAnc, ancErr := git.IsAncestor(newBase, b.Branch)
|
|
if ancErr == nil && isAnc {
|
|
if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase {
|
|
continue // No rebase needed
|
|
}
|
|
}
|
|
|
|
if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil {
|
|
conflict := &modifyview.ConflictInfo{
|
|
Branch: b.Branch,
|
|
}
|
|
if files, ferr := git.ConflictedFiles(); ferr == nil {
|
|
conflict.ConflictedFiles = files
|
|
}
|
|
|
|
// Save conflict state so --continue can resume
|
|
remaining := make([]string, 0)
|
|
for j := i + 1; j < len(s.Branches); j++ {
|
|
if !s.Branches[j].IsMerged() {
|
|
remaining = append(remaining, s.Branches[j].Branch)
|
|
}
|
|
}
|
|
stateFile.Phase = PhaseConflict
|
|
stateFile.ConflictBranch = b.Branch
|
|
stateFile.ConflictType = "rebase"
|
|
stateFile.RemainingBranches = remaining
|
|
stateFile.OriginalBranch = currentBranch
|
|
stateFile.OriginalRefs = originalParentTips
|
|
if saveErr := SaveState(gitDir, stateFile); saveErr != nil {
|
|
cfg.Warningf("failed to save conflict state: %v", saveErr)
|
|
}
|
|
|
|
// Save stack metadata so far (renames, folds, drops already applied)
|
|
if saveErr := stack.SaveWithLock(gitDir, sf, lock); saveErr != nil {
|
|
cfg.Warningf("failed to save stack metadata: %v", saveErr)
|
|
}
|
|
|
|
return nil, conflict, fmt.Errorf("rebase conflict on %s", b.Branch)
|
|
}
|
|
|
|
cfg.Successf("Rebased %s onto %s", b.Branch, newBase)
|
|
result.MovedBranches++
|
|
}
|
|
|
|
// Restore original branch
|
|
_ = git.CheckoutBranch(currentBranch)
|
|
|
|
// Update base SHAs
|
|
updateBaseSHAs(s)
|
|
|
|
// Update state file phase
|
|
if s.ID != "" {
|
|
stateFile.Phase = PhasePendingSubmit
|
|
if err := SaveState(gitDir, stateFile); err != nil {
|
|
cfg.Warningf("failed to update modify state: %s", err)
|
|
}
|
|
} else {
|
|
// No remote stack — clear the state file
|
|
ClearState(gitDir)
|
|
}
|
|
|
|
// Save stack metadata — this must succeed since git refs have been rewritten
|
|
if err := stack.SaveWithLock(gitDir, sf, lock); err != nil {
|
|
return nil, nil, fmt.Errorf("saving stack metadata: %w", err)
|
|
}
|
|
|
|
return result, nil, nil
|
|
}
|
|
|
|
// ContinueApply resumes a modify operation after the user resolves a rebase conflict.
|
|
// It finishes the in-progress git rebase, then continues the cascading rebase for
|
|
// remaining branches stored in the state file.
|
|
func ContinueApply(
|
|
cfg *config.Config,
|
|
gitDir string,
|
|
updateBaseSHAs func(*stack.Stack),
|
|
) error {
|
|
state, err := LoadState(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading modify state: %w", err)
|
|
}
|
|
if state == nil {
|
|
return fmt.Errorf("no modify state file found")
|
|
}
|
|
if state.Phase != PhaseConflict {
|
|
return fmt.Errorf("no modify conflict in progress (phase: %s)", state.Phase)
|
|
}
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading stack: %w", err)
|
|
}
|
|
|
|
// Acquire lock for the duration of the operation
|
|
lock, err := stack.Lock(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("acquiring stack lock: %w", err)
|
|
}
|
|
defer lock.Unlock()
|
|
|
|
// Find the stack using the saved index for reliable identification.
|
|
var s *stack.Stack
|
|
if state.StackIndex >= 0 && state.StackIndex < len(sf.Stacks) {
|
|
s = &sf.Stacks[state.StackIndex]
|
|
}
|
|
if s == nil {
|
|
return fmt.Errorf("stack at index %d not found (stack file may have changed)", state.StackIndex)
|
|
}
|
|
|
|
// Finish the in-progress git operation (rebase or cherry-pick)
|
|
if state.ConflictType == "cherry_pick" {
|
|
if err := git.CherryPickContinue(); err != nil {
|
|
return fmt.Errorf("cherry-pick continue failed — resolve remaining conflicts and try again: %w", err)
|
|
}
|
|
cfg.Successf("Folded %s into %s", state.FoldBranch, state.FoldTarget)
|
|
|
|
// Remove the folded branch from stack metadata
|
|
foldIdx := s.IndexOf(state.FoldBranch)
|
|
if foldIdx >= 0 && foldIdx < len(s.Branches) {
|
|
s.Branches = append(s.Branches[:foldIdx], s.Branches[foldIdx+1:]...)
|
|
}
|
|
} else {
|
|
// Rebase conflict
|
|
if git.IsRebaseInProgress() {
|
|
if err := git.RebaseContinue(); err != nil {
|
|
return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err)
|
|
}
|
|
}
|
|
cfg.Successf("Rebased %s", state.ConflictBranch)
|
|
}
|
|
|
|
// Continue cascading rebase for remaining branches
|
|
for _, branchName := range state.RemainingBranches {
|
|
idx := s.IndexOf(branchName)
|
|
if idx < 0 {
|
|
cfg.Warningf("branch %s no longer in stack, skipping", branchName)
|
|
continue
|
|
}
|
|
b := s.Branches[idx]
|
|
if b.IsMerged() {
|
|
continue
|
|
}
|
|
|
|
var newBase string
|
|
if idx == 0 {
|
|
newBase = s.Trunk.Branch
|
|
} else {
|
|
newBase = s.ActiveBaseBranch(b.Branch)
|
|
}
|
|
|
|
// Use original parent tip or merge-base as oldBase
|
|
oldBase := ""
|
|
if state.OriginalRefs != nil {
|
|
oldBase = state.OriginalRefs[b.Branch]
|
|
}
|
|
if oldBase == "" {
|
|
if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil {
|
|
oldBase = mb
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Check if rebase is needed
|
|
isAnc, ancErr := git.IsAncestor(newBase, b.Branch)
|
|
if ancErr == nil && isAnc {
|
|
if mb, mberr := git.MergeBase(newBase, b.Branch); mberr == nil && mb == oldBase {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := git.RebaseOnto(newBase, oldBase, b.Branch); err != nil {
|
|
// Another conflict — update state and bail
|
|
remaining := make([]string, 0)
|
|
foundCurrent := false
|
|
for _, rn := range state.RemainingBranches {
|
|
if rn == branchName {
|
|
foundCurrent = true
|
|
continue
|
|
}
|
|
if foundCurrent {
|
|
remaining = append(remaining, rn)
|
|
}
|
|
}
|
|
state.ConflictBranch = branchName
|
|
state.RemainingBranches = remaining
|
|
_ = SaveState(gitDir, state)
|
|
|
|
cfg.Warningf("Conflict rebasing %s", branchName)
|
|
if files, ferr := git.ConflictedFiles(); ferr == nil {
|
|
for _, f := range files {
|
|
cfg.Printf(" %s", f)
|
|
}
|
|
}
|
|
cfg.Printf("")
|
|
cfg.Printf("Resolve the conflicts, stage with `%s`, then run `%s`",
|
|
cfg.ColorCyan("git add <file>"),
|
|
cfg.ColorCyan("gh stack modify --continue"))
|
|
cfg.Printf("Or restore the stack with `%s`",
|
|
cfg.ColorCyan("gh stack modify --abort"))
|
|
return fmt.Errorf("rebase conflict on %s", branchName)
|
|
}
|
|
|
|
cfg.Successf("Rebased %s onto %s", branchName, newBase)
|
|
}
|
|
|
|
// All rebases done — restore original branch
|
|
if state.OriginalBranch != "" {
|
|
_ = git.CheckoutBranch(state.OriginalBranch)
|
|
}
|
|
|
|
// Update base SHAs
|
|
updateBaseSHAs(s)
|
|
|
|
// Transition to pending_submit or clear
|
|
if s.ID != "" {
|
|
state.Phase = PhasePendingSubmit
|
|
state.ConflictBranch = ""
|
|
state.RemainingBranches = nil
|
|
state.OriginalRefs = nil
|
|
if err := SaveState(gitDir, state); err != nil {
|
|
cfg.Warningf("failed to update modify state: %s", err)
|
|
}
|
|
} else {
|
|
ClearState(gitDir)
|
|
}
|
|
|
|
// Save stack metadata
|
|
if err := stack.SaveWithLock(gitDir, sf, lock); err != nil {
|
|
cfg.Warningf("failed to save stack: %v", err)
|
|
}
|
|
|
|
cfg.Successf("Stack modified successfully")
|
|
if state.PriorRemoteStackID != "" {
|
|
cfg.Printf("")
|
|
cfg.Printf("Run `%s` to push your changes and update the stack of PRs on GitHub",
|
|
cfg.ColorCyan("gh stack submit"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Unwind restores the stack to its pre-modify state using the snapshot.
|
|
// stackIndex is the index of the stack in sf.Stacks at modify start time.
|
|
func Unwind(cfg *config.Config, gitDir string, snapshot Snapshot, stackIndex int, sf *stack.StackFile, plan []Action) error {
|
|
// Abort any in-progress rebase
|
|
if git.IsRebaseInProgress() {
|
|
_ = git.RebaseAbort()
|
|
}
|
|
|
|
// Restore branch tips
|
|
snapshotNames := make(map[string]bool, len(snapshot.Branches))
|
|
for _, bs := range snapshot.Branches {
|
|
snapshotNames[bs.Name] = true
|
|
if !git.BranchExists(bs.Name) {
|
|
// Branch was renamed — try to find it by SHA and recreate
|
|
if err := git.CreateBranch(bs.Name, bs.TipSHA); err != nil {
|
|
cfg.Warningf("failed to restore branch %s: %v", bs.Name, err)
|
|
continue
|
|
}
|
|
} else {
|
|
if err := git.CheckoutBranch(bs.Name); err != nil {
|
|
cfg.Warningf("failed to checkout %s for unwind: %v", bs.Name, err)
|
|
continue
|
|
}
|
|
if err := git.ResetHard(bs.TipSHA); err != nil {
|
|
cfg.Warningf("failed to reset %s to %s: %v", bs.Name, bs.TipSHA[:7], err)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up branches created by renames during the partial apply
|
|
for _, action := range plan {
|
|
if action.Type == "rename" && action.NewName != "" {
|
|
if !snapshotNames[action.NewName] && git.BranchExists(action.NewName) {
|
|
_ = git.DeleteBranch(action.NewName, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore stack metadata from snapshot
|
|
var restoredStack stack.Stack
|
|
if err := json.Unmarshal(snapshot.StackMetadata, &restoredStack); err != nil {
|
|
return fmt.Errorf("restoring stack metadata: %w", err)
|
|
}
|
|
|
|
// Replace the stack at the saved index
|
|
if stackIndex >= 0 && stackIndex < len(sf.Stacks) {
|
|
sf.Stacks[stackIndex] = restoredStack
|
|
}
|
|
|
|
// Save restored stack
|
|
if err := stack.Save(gitDir, sf); err != nil {
|
|
cfg.Warningf("failed to save restored stack: %v", err)
|
|
}
|
|
|
|
// Clear state file
|
|
ClearState(gitDir)
|
|
|
|
// Checkout the first snapshot branch
|
|
if len(snapshot.Branches) > 0 {
|
|
_ = git.CheckoutBranch(snapshot.Branches[0].Name)
|
|
}
|
|
|
|
cfg.Successf("Stack restored to pre-modify state")
|
|
return nil
|
|
}
|
|
|
|
// UnwindFromStateFile restores the stack from a modify state file (for --abort).
|
|
func UnwindFromStateFile(cfg *config.Config, gitDir string) error {
|
|
state, err := LoadState(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading modify state: %w", err)
|
|
}
|
|
if state == nil {
|
|
return fmt.Errorf("no modify state file found")
|
|
}
|
|
|
|
sf, err := stack.Load(gitDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading stack: %w", err)
|
|
}
|
|
|
|
return Unwind(cfg, gitDir, state.Snapshot, state.StackIndex, sf, state.Plan)
|
|
}
|