Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions internal/git/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package git

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
Expand Down Expand Up @@ -275,6 +277,50 @@ func HasConflicts() bool {
return len(output) > 0
}

// IsGitMergeInProgress checks if git is in a merge state by looking for MERGE_HEAD
func IsGitMergeInProgress() bool {
gitDir, err := GetGitDir()
if err != nil {
return false
}
_, err = os.Stat(filepath.Join(gitDir, "MERGE_HEAD"))
return err == nil
}

// IsGitRebaseInProgress checks if git is in a rebase state by looking for
// rebase-merge/ (merge backend, including interactive) or rebase-apply/ (legacy apply backend)
func IsGitRebaseInProgress() bool {
gitDir, err := GetGitDir()
if err != nil {
return false
}
if _, err := os.Stat(filepath.Join(gitDir, "rebase-merge")); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(gitDir, "rebase-apply")); err == nil {
return true
}
return false
}

// IsGitSquashMergeInProgress checks if git is in a squash merge state.
// Squash merges create SQUASH_MSG but not MERGE_HEAD. However, SQUASH_MSG also
// appears during interactive rebase squash steps, so we exclude that case.
func IsGitSquashMergeInProgress() bool {
gitDir, err := GetGitDir()
if err != nil {
return false
}
if _, err := os.Stat(filepath.Join(gitDir, "SQUASH_MSG")); err != nil {
return false
}
// Exclude interactive rebase squash steps
if _, err := os.Stat(filepath.Join(gitDir, "rebase-merge")); err == nil {
return false
}
return true
}

// MergeAbort aborts the current merge
func MergeAbort() error {
cmd := exec.Command("git", "merge", "--abort")
Expand Down
47 changes: 45 additions & 2 deletions internal/mergestate/mergestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,51 @@ func ClearMergeState() error {
return nil
}

// IsMergeInProgress checks if there's a merge in progress
// isStateValid checks whether a loaded merge state is still meaningful by
// validating it against the actual git repository state. This detects stale
// state files left by manual intervention, crashes, or interrupted operations.
func isStateValid(state *MergeState) bool {
// Critical fields must be non-empty
if state.BranchType == "" || state.FullBranchName == "" || state.CurrentStep == "" {
return false
}

switch state.CurrentStep {
case "merge", "update_children":
// Git must actually be in a merge, rebase, or squash merge state
return git.IsGitMergeInProgress() || git.IsGitRebaseInProgress() || git.IsGitSquashMergeInProgress()
case "create_tag", "delete_branch":
// The topic branch must still exist
return git.BranchExists(state.FullBranchName) == nil
default:
return false
}
}

// IsMergeInProgress checks if there's a valid merge in progress. If a state
// file exists but is stale (e.g., manual resolution, crash, or missing branch),
// it is automatically cleared and false is returned.
func IsMergeInProgress() bool {
state, err := LoadMergeState()
return err == nil && state != nil
if err != nil {
// Corrupted or unreadable state file — clear it
if clearErr := ClearMergeState(); clearErr != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to clear stale merge state: %v\n", clearErr)
} else {
fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n")
}
return false
}
if state == nil {
return false
}
if !isStateValid(state) {
if err := ClearMergeState(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to clear stale merge state: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n")
}
Comment on lines +166 to +171
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClearMergeState() errors are also silently ignored here. If removal fails (e.g., permissions), users may keep hitting the same stale-state behavior without understanding why. Consider emitting a warning to stderr when ClearMergeState returns an error in this path too.

Copilot uses AI. Check for mistakes.
return false
}
return true
}
226 changes: 226 additions & 0 deletions test/cmd/finish_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/gittower/git-flow-next/internal/mergestate"
"github.com/gittower/git-flow-next/test/testutil"
)

Expand Down Expand Up @@ -290,3 +291,228 @@ func TestFinishStateBackwardCompatibility(t *testing.T) {
t.Error("Feature changes not found in develop branch")
}
}

// TestFinishDetectsStaleStateEmptyFields tests that stale state with empty critical fields is auto-cleared.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Writes a merge state file with empty BranchType
// 3. Creates and finishes a feature branch normally
// 4. Verifies the stale state was cleared and finish succeeded
func TestFinishDetectsStaleStateEmptyFields(t *testing.T) {
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
if err != nil {
t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
}

// Write stale state with empty BranchType
testutil.WriteMergeState(t, dir, &mergestate.MergeState{
Action: "finish",
BranchType: "", // empty — should be detected as stale
FullBranchName: "feature/old-branch",
CurrentStep: "merge",
})

// Create and finish a new feature — should succeed because stale state is cleared
output, err = testutil.RunGitFlow(t, dir, "feature", "start", "new-feature")
if err != nil {
t.Fatalf("Failed to start feature: %v\nOutput: %s", err, output)
}
testutil.WriteFile(t, dir, "new.txt", "content")
_, err = testutil.RunGit(t, dir, "add", "new.txt")
if err != nil {
t.Fatalf("Failed to add file: %v", err)
}
_, err = testutil.RunGit(t, dir, "commit", "-m", "New feature commit")
if err != nil {
t.Fatalf("Failed to commit: %v", err)
}

output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "new-feature")
if err != nil {
t.Fatalf("Expected finish to succeed after clearing stale state, got error: %v\nOutput: %s", err, output)
}

// Verify stale state was cleared
if testutil.GitFlowMergeStateExists(t, dir) {
t.Error("Expected merge state to be cleared")
}
}

// TestFinishDetectsStaleStateMergeStepNoConflict tests that state at merge step is cleared when
// git is not actually in a merge or rebase state.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Writes a merge state file at the merge step
// 3. Runs feature finish which checks for merge in progress
// 4. Verifies stale state is cleared and a new finish can proceed
func TestFinishDetectsStaleStateMergeStepNoConflict(t *testing.T) {
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
if err != nil {
t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
}

// Write stale state claiming merge step but git is not in merge state
testutil.WriteMergeState(t, dir, &mergestate.MergeState{
Action: "finish",
BranchType: "feature",
BranchName: "old-branch",
FullBranchName: "feature/old-branch",
CurrentStep: "merge",
ParentBranch: "develop",
MergeStrategy: "merge",
})

// Create and finish a new feature — stale state should be auto-cleared
output, err = testutil.RunGitFlow(t, dir, "feature", "start", "fresh-feature")
if err != nil {
t.Fatalf("Failed to start feature: %v\nOutput: %s", err, output)
}
testutil.WriteFile(t, dir, "fresh.txt", "content")
_, err = testutil.RunGit(t, dir, "add", "fresh.txt")
if err != nil {
t.Fatalf("Failed to add file: %v", err)
}
_, err = testutil.RunGit(t, dir, "commit", "-m", "Fresh feature commit")
if err != nil {
t.Fatalf("Failed to commit: %v", err)
}

output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "fresh-feature")
if err != nil {
t.Fatalf("Expected finish to succeed after clearing stale state, got error: %v\nOutput: %s", err, output)
}

if strings.Contains(output, "merge in progress") {
t.Error("Expected stale state to be cleared, but got merge in progress error")
}
}

// TestFinishDetectsStaleStateDeleteStepBranchGone tests that state at delete_branch step is
// cleared when the topic branch no longer exists.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Writes a merge state file at delete_branch step referencing a non-existent branch
// 3. Runs feature finish to verify stale state is cleared
// 4. Verifies a new finish can proceed normally
func TestFinishDetectsStaleStateDeleteStepBranchGone(t *testing.T) {
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
if err != nil {
t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
}

// Write stale state at delete_branch step for a branch that doesn't exist
testutil.WriteMergeState(t, dir, &mergestate.MergeState{
Action: "finish",
BranchType: "feature",
BranchName: "deleted-branch",
FullBranchName: "feature/deleted-branch",
CurrentStep: "delete_branch",
ParentBranch: "develop",
MergeStrategy: "merge",
})

// Create and finish a new feature — stale state should be auto-cleared
output, err = testutil.RunGitFlow(t, dir, "feature", "start", "another-feature")
if err != nil {
t.Fatalf("Failed to start feature: %v\nOutput: %s", err, output)
}
testutil.WriteFile(t, dir, "another.txt", "content")
_, err = testutil.RunGit(t, dir, "add", "another.txt")
if err != nil {
t.Fatalf("Failed to add file: %v", err)
}
_, err = testutil.RunGit(t, dir, "commit", "-m", "Another feature commit")
if err != nil {
t.Fatalf("Failed to commit: %v", err)
}

output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "another-feature")
if err != nil {
t.Fatalf("Expected finish to succeed after clearing stale state, got error: %v\nOutput: %s", err, output)
}

if testutil.GitFlowMergeStateExists(t, dir) {
t.Error("Expected merge state to be cleared after finish")
}
}

// TestFinishValidStateMergeStepWithConflict tests that legitimate merge state is NOT cleared
// when git is actually in a merge conflict state.
// Steps:
// 1. Sets up a test repository and initializes git-flow
// 2. Creates a feature branch with conflicting changes
// 3. Attempts to finish (produces merge conflict, creating valid state)
// 4. Verifies the merge state is preserved (not cleared as stale)
func TestFinishValidStateMergeStepWithConflict(t *testing.T) {
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

output, err := testutil.RunGitFlow(t, dir, "init", "--defaults")
if err != nil {
t.Fatalf("Failed to initialize git-flow: %v\nOutput: %s", err, output)
}

// Create feature with content
output, err = testutil.RunGitFlow(t, dir, "feature", "start", "conflict-test")
if err != nil {
t.Fatalf("Failed to start feature: %v\nOutput: %s", err, output)
}
testutil.WriteFile(t, dir, "conflict.txt", "feature content")
_, err = testutil.RunGit(t, dir, "add", "conflict.txt")
if err != nil {
t.Fatalf("Failed to add file: %v", err)
}
_, err = testutil.RunGit(t, dir, "commit", "-m", "Feature commit")
if err != nil {
t.Fatalf("Failed to commit: %v", err)
}

// Add conflicting content on develop
_, err = testutil.RunGit(t, dir, "checkout", "develop")
if err != nil {
t.Fatalf("Failed to checkout develop: %v", err)
}
testutil.WriteFile(t, dir, "conflict.txt", "develop content")
_, err = testutil.RunGit(t, dir, "add", "conflict.txt")
if err != nil {
t.Fatalf("Failed to add file: %v", err)
}
_, err = testutil.RunGit(t, dir, "commit", "-m", "Develop commit")
if err != nil {
t.Fatalf("Failed to commit: %v", err)
}

// Switch back and finish — will produce conflict
_, err = testutil.RunGit(t, dir, "checkout", "feature/conflict-test")
if err != nil {
t.Fatalf("Failed to checkout feature branch: %v", err)
}
output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "conflict-test")

// Finish should fail with conflict
if err == nil {
t.Fatal("Expected finish to fail with merge conflict")
}

// The merge state should be preserved — this is a legitimate conflict
if !testutil.GitFlowMergeStateExists(t, dir) {
t.Error("Expected merge state to be preserved during legitimate conflict")
}

state, stateErr := testutil.LoadMergeState(t, dir)
if stateErr != nil {
t.Fatalf("Failed to load merge state: %v", stateErr)
}
if state.CurrentStep != "merge" {
t.Errorf("Expected state step 'merge', got '%s'", state.CurrentStep)
}
}
Loading