Files
github__gh-stack/internal/modify/state.go
Sameen Karim 7a268fc380 modify command (#72)
* 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>
2026-05-04 22:34:42 -04:00

139 lines
4.6 KiB
Go

package modify
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
const stateFileName = "gh-stack-modify-state"
const (
PhaseApplying = "applying"
PhaseConflict = "conflict"
PhasePendingSubmit = "pending_submit"
)
// StateFile holds the state of an in-progress or pending-submit modify operation.
// It is stored at .git/gh-stack-modify-state.
type StateFile struct {
SchemaVersion int `json:"schema_version"`
StackName string `json:"stack_name"`
StackIndex int `json:"stack_index"` // index in StackFile.Stacks at modify start
StartedAt time.Time `json:"started_at"`
Phase string `json:"phase"` // "applying", "conflict", or "pending_submit"
PriorRemoteStackID string `json:"prior_remote_stack_id,omitempty"`
Snapshot Snapshot `json:"snapshot"`
Plan []Action `json:"plan"`
// Conflict state — populated when phase is "conflict"
ConflictBranch string `json:"conflict_branch,omitempty"`
ConflictType string `json:"conflict_type,omitempty"` // "rebase" or "cherry_pick"
RemainingBranches []string `json:"remaining_branches,omitempty"`
OriginalBranch string `json:"original_branch,omitempty"`
OriginalRefs map[string]string `json:"original_refs,omitempty"`
// Cherry-pick conflict context — which fold was in progress
FoldBranch string `json:"fold_branch,omitempty"` // branch being folded
FoldTarget string `json:"fold_target,omitempty"` // branch receiving the cherry-pick
}
// Snapshot captures the pre-modify state for unwind/recovery.
type Snapshot struct {
Branches []BranchSnapshot `json:"branches"`
StackMetadata json.RawMessage `json:"stack_metadata"`
}
// BranchSnapshot stores the state of a single branch before modification.
type BranchSnapshot struct {
Name string `json:"name"`
TipSHA string `json:"tip_sha"`
Position int `json:"position"`
}
// Action represents a single staged action from the TUI.
type Action struct {
Type string `json:"type"` // "drop", "fold_down", "fold_up", "move", "rename"
Branch string `json:"branch"`
NewPosition int `json:"new_position,omitempty"` // for "move"
NewName string `json:"new_name,omitempty"` // for "rename"
}
// StatePath returns the full path to the modify state file.
func StatePath(gitDir string) string {
return filepath.Join(gitDir, stateFileName)
}
// LoadState reads the modify state file from the git directory.
// Returns nil, nil if the file does not exist.
func LoadState(gitDir string) (*StateFile, error) {
data, err := os.ReadFile(StatePath(gitDir))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("reading modify state: %w", err)
}
var state StateFile
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("parsing modify state: %w", err)
}
return &state, nil
}
// SaveState writes the modify state file atomically (write to temp, then rename).
func SaveState(gitDir string, state *StateFile) error {
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshaling modify state: %w", err)
}
target := StatePath(gitDir)
tmp := target + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return fmt.Errorf("writing modify state: %w", err)
}
// Remove existing target before rename for Windows compatibility
// (os.Rename fails on Windows if the target already exists).
_ = os.Remove(target)
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("committing modify state: %w", err)
}
return nil
}
// ClearState removes the modify state file.
func ClearState(gitDir string) {
_ = os.Remove(StatePath(gitDir))
}
// StateExists returns true if a modify state file exists.
func StateExists(gitDir string) bool {
_, err := os.Stat(StatePath(gitDir))
return err == nil
}
// CheckStateGuard checks if a modify state file exists with phase "applying"
// and returns an error if so. This is used as a guard at the top of commands that
// should not run while a modify is in progress.
func CheckStateGuard(gitDir string) error {
state, err := LoadState(gitDir)
if err != nil {
return nil // ignore read errors
}
if state == nil {
return nil
}
if state.Phase == PhaseApplying {
return fmt.Errorf("a modify session was interrupted — run `gh stack modify --abort` to restore your stack")
}
if state.Phase == PhaseConflict {
return fmt.Errorf("a modify has unresolved conflicts — run `gh stack modify --continue` or `gh stack modify --abort`")
}
return nil
}