Detect and auto-clear stale merge state#106
Conversation
Adds validation to IsMergeInProgress() so that stale merge state files are automatically detected and cleared instead of blocking operations with confusing errors. The validation checks three things: critical fields must be non-empty, conflict steps (merge, update_children) must have an actual git merge/rebase/squash-merge in progress, and non-conflict steps (create_tag, delete_branch) must reference a branch that still exists. Three new git state detection functions are added to internal/git: IsGitMergeInProgress (checks MERGE_HEAD), IsGitRebaseInProgress (checks rebase-merge/ and rebase-apply/), and IsGitSquashMergeInProgress (checks SQUASH_MSG without rebase-merge/, since SQUASH_MSG also appears during interactive rebase squash steps). When stale state is detected, it is cleared and a note is printed to stderr so the user knows what happened. Resolves #101
There was a problem hiding this comment.
Pull request overview
Adds validation to internal/mergestate.IsMergeInProgress() so stale merge.json state is detected and auto-cleared, reducing confusing “unknown branch type” failures and unblocking normal operations across all callers.
Changes:
- Validate merge state contents and reconcile it with actual Git state; auto-clear invalid state and emit a note to stderr.
- Add Git repository state detection helpers for merge, rebase, and squash-merge in
internal/git/repo.go. - Expand test utilities and add/adjust integration + unit tests around stale-state clearing and Git state detection.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
internal/mergestate/mergestate.go |
Adds isStateValid() and updates IsMergeInProgress() to validate/clear stale merge state. |
internal/git/repo.go |
Adds IsGitMergeInProgress, IsGitRebaseInProgress, IsGitSquashMergeInProgress helpers. |
test/cmd/finish_state_test.go |
Adds integration tests covering stale-state auto-clear scenarios. |
test/internal/git/repo_test.go |
Adds unit tests for the new Git in-progress detection helpers. |
test/internal/git/worktree_test.go |
Updates worktree merge state tests to satisfy the new validation rules. |
test/testutil/testutil.go |
Adds helpers to write and assert existence of git-flow merge state in tests. |
| if err != nil || state == nil { | ||
| return false | ||
| } | ||
| if !isStateValid(state) { | ||
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | ||
| ClearMergeState() |
There was a problem hiding this comment.
IsMergeInProgress returns false on LoadMergeState errors but does not clear a corrupted/unreadable merge.json. This leaves stale state on disk (and can keep printing confusing behavior later). Consider treating non-nil errors from LoadMergeState as stale state: print the note and attempt ClearMergeState (handling any ClearMergeState error) before returning false.
| if err != nil || state == nil { | |
| return false | |
| } | |
| if !isStateValid(state) { | |
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | |
| ClearMergeState() | |
| if err != nil { | |
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | |
| if clearErr := ClearMergeState(); clearErr != nil { | |
| fmt.Fprintf(os.Stderr, "Warning: Failed to clear stale merge state after load error: %v\n", clearErr) | |
| } | |
| return false | |
| } | |
| if state == nil { | |
| return false | |
| } | |
| if !isStateValid(state) { | |
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | |
| if err := ClearMergeState(); err != nil { | |
| fmt.Fprintf(os.Stderr, "Warning: Failed to clear stale merge state: %v\n", err) | |
| } |
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | ||
| ClearMergeState() |
There was a problem hiding this comment.
ClearMergeState() returns an error but the result is currently ignored. If the remove fails (permissions, locked file, etc.), IsMergeInProgress will keep reporting and re-printing the stale-state note indefinitely. Handle and report the ClearMergeState error (and consider only printing the note once the clear succeeds).
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | |
| ClearMergeState() | |
| if err := ClearMergeState(); err != nil { | |
| fmt.Fprintf(os.Stderr, "Warning: failed to clear stale merge state from a previous operation: %v\n", err) | |
| } else { | |
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | |
| } |
| t.Helper() | ||
| gitDir, err := getGitDirForRepo(dir) | ||
| if err != nil { | ||
| return false |
There was a problem hiding this comment.
GitFlowMergeStateExists silently returns false when getGitDirForRepo fails. That can produce false negatives and make tests pass even if the repo is misconfigured. Prefer failing the test (t.Fatalf) on git dir resolution errors so assertions are reliable.
| return false | |
| t.Fatalf("Failed to determine git directory for repo %s: %v", dir, err) |
| // Start merge that will conflict | ||
| _, _ = testutil.RunGit(t, dir, "merge", "feature") | ||
|
|
There was a problem hiding this comment.
This test intentionally triggers a merge conflict, but it ignores the error from git merge. If the merge unexpectedly succeeds (or fails for a different reason), the assertion on IsGitMergeInProgress becomes less meaningful. Capture the output/error and assert that the merge failed due to conflicts before checking IsGitMergeInProgress.
| // Switch to feature and rebase onto main (will conflict) | ||
| _, err = testutil.RunGit(t, dir, "checkout", "feature") | ||
| if err != nil { | ||
| t.Fatalf("Failed to checkout feature: %v", err) | ||
| } | ||
| _, _ = testutil.RunGit(t, dir, "rebase", "main") | ||
|
|
There was a problem hiding this comment.
This test intentionally triggers a rebase conflict, but it ignores the error from git rebase. Assert that the rebase returned an error (and ideally that it's conflict-related) before asserting IsGitRebaseInProgress.
| testutil.RunGit(t, dir, "add", "another.txt") | ||
| testutil.RunGit(t, dir, "commit", "-m", "Another feature commit") |
There was a problem hiding this comment.
Errors from these git setup commands are ignored. Capture and assert errors from RunGit (add/commit) so the test reliably creates the intended history before running feature finish.
| testutil.RunGit(t, dir, "add", "another.txt") | |
| testutil.RunGit(t, dir, "commit", "-m", "Another feature commit") | |
| output, err = testutil.RunGit(t, dir, "add", "another.txt") | |
| if err != nil { | |
| t.Fatalf("Failed to add file another.txt for feature another-feature: %v\nOutput: %s", err, output) | |
| } | |
| output, err = testutil.RunGit(t, dir, "commit", "-m", "Another feature commit") | |
| if err != nil { | |
| t.Fatalf("Failed to commit file another.txt for feature another-feature: %v\nOutput: %s", err, output) | |
| } |
| BranchType: "feature", | ||
| BranchName: "test-feature", | ||
| CurrentStep: "merge", | ||
| ParentBranch: "develop", | ||
| CurrentStep: "delete_branch", | ||
| ParentBranch: "main", | ||
| MergeStrategy: "merge", | ||
| FullBranchName: "feature/test-feature", | ||
| FullBranchName: "worktree-branch", |
There was a problem hiding this comment.
In this test state, BranchName (test-feature) does not correspond to FullBranchName (worktree-branch). Since BranchName is intended to be the (short) name of the branch being finished and FullBranchName the full ref, keeping them consistent will make the test data closer to real merge state and reduce brittleness if validation expands later.
| BranchName: "worktree1-feature", | ||
| Action: "finish", | ||
| BranchType: "feature", | ||
| BranchName: "worktree1-feature", |
There was a problem hiding this comment.
In this test state, BranchName (worktree1-feature) does not correspond to FullBranchName (worktree1-branch). Consider making these fields consistent (short vs full branch name) so the saved state mirrors real-world finish state more closely.
| BranchName: "worktree1-feature", | |
| BranchName: "worktree1-branch", |
| testutil.RunGit(t, dir, "add", "conflict.txt") | ||
| testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | ||
|
|
||
| // Add conflicting content on develop | ||
| testutil.RunGit(t, dir, "checkout", "develop") | ||
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | ||
| testutil.RunGit(t, dir, "add", "conflict.txt") | ||
| testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | ||
|
|
||
| // Switch back and finish — will produce conflict | ||
| testutil.RunGit(t, dir, "checkout", "feature/conflict-test") |
There was a problem hiding this comment.
Errors from these git setup commands are ignored (add/commit). If any of them fails, the test may not actually create the intended conflicting history. Capture and assert errors from RunGit calls so the conflict setup is reliable.
| testutil.RunGit(t, dir, "add", "conflict.txt") | |
| testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | |
| // Add conflicting content on develop | |
| testutil.RunGit(t, dir, "checkout", "develop") | |
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | |
| testutil.RunGit(t, dir, "add", "conflict.txt") | |
| testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | |
| // Switch back and finish — will produce conflict | |
| testutil.RunGit(t, dir, "checkout", "feature/conflict-test") | |
| output, err = testutil.RunGit(t, dir, "add", "conflict.txt") | |
| if err != nil { | |
| t.Fatalf("Failed to add feature conflict file: %v\nOutput: %s", err, output) | |
| } | |
| output, err = testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | |
| if err != nil { | |
| t.Fatalf("Failed to commit feature conflict file: %v\nOutput: %s", err, output) | |
| } | |
| // Add conflicting content on develop | |
| output, err = testutil.RunGit(t, dir, "checkout", "develop") | |
| if err != nil { | |
| t.Fatalf("Failed to checkout develop branch: %v\nOutput: %s", err, output) | |
| } | |
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | |
| output, err = testutil.RunGit(t, dir, "add", "conflict.txt") | |
| if err != nil { | |
| t.Fatalf("Failed to add develop conflict file: %v\nOutput: %s", err, output) | |
| } | |
| output, err = testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | |
| if err != nil { | |
| t.Fatalf("Failed to commit develop conflict file: %v\nOutput: %s", err, output) | |
| } | |
| // Switch back and finish — will produce conflict | |
| output, err = testutil.RunGit(t, dir, "checkout", "feature/conflict-test") | |
| if err != nil { | |
| t.Fatalf("Failed to checkout feature/conflict-test branch: %v\nOutput: %s", err, output) | |
| } |
| testutil.RunGit(t, dir, "add", "conflict.txt") | ||
| testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | ||
|
|
||
| // Add conflicting content on develop | ||
| testutil.RunGit(t, dir, "checkout", "develop") | ||
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | ||
| testutil.RunGit(t, dir, "add", "conflict.txt") | ||
| testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | ||
|
|
||
| // Switch back and finish — will produce conflict | ||
| testutil.RunGit(t, dir, "checkout", "feature/conflict-test") |
There was a problem hiding this comment.
Errors from these git setup commands are ignored (checkout/add/commit). Capture and assert errors from RunGit calls so the conflict setup is deterministic before invoking feature finish.
| testutil.RunGit(t, dir, "add", "conflict.txt") | |
| testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | |
| // Add conflicting content on develop | |
| testutil.RunGit(t, dir, "checkout", "develop") | |
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | |
| testutil.RunGit(t, dir, "add", "conflict.txt") | |
| testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | |
| // Switch back and finish — will produce conflict | |
| testutil.RunGit(t, dir, "checkout", "feature/conflict-test") | |
| output, err = testutil.RunGit(t, dir, "add", "conflict.txt") | |
| if err != nil { | |
| t.Fatalf("Failed to add conflict.txt on feature/conflict-test: %v\nOutput: %s", err, output) | |
| } | |
| output, err = testutil.RunGit(t, dir, "commit", "-m", "Feature commit") | |
| if err != nil { | |
| t.Fatalf("Failed to commit feature changes on feature/conflict-test: %v\nOutput: %s", err, output) | |
| } | |
| // Add conflicting content on develop | |
| output, err = testutil.RunGit(t, dir, "checkout", "develop") | |
| if err != nil { | |
| t.Fatalf("Failed to checkout branch develop: %v\nOutput: %s", err, output) | |
| } | |
| testutil.WriteFile(t, dir, "conflict.txt", "develop content") | |
| output, err = testutil.RunGit(t, dir, "add", "conflict.txt") | |
| if err != nil { | |
| t.Fatalf("Failed to add conflict.txt on develop: %v\nOutput: %s", err, output) | |
| } | |
| output, err = testutil.RunGit(t, dir, "commit", "-m", "Develop commit") | |
| if err != nil { | |
| t.Fatalf("Failed to commit conflicting changes on develop: %v\nOutput: %s", err, output) | |
| } | |
| // Switch back and finish — will produce conflict | |
| output, err = testutil.RunGit(t, dir, "checkout", "feature/conflict-test") | |
| if err != nil { | |
| t.Fatalf("Failed to checkout branch feature/conflict-test: %v\nOutput: %s", err, output) | |
| } |
Clear corrupted merge state files on LoadMergeState errors instead of silently leaving them on disk. Only print the stale-state note when ClearMergeState succeeds. Add error checks to git setup commands in stale-state integration tests. Fail GitFlowMergeStateExists on git dir errors instead of silently returning false. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed review feedback in f8bb672. Accepted (8 comments):
Dismissed (5 comments):
|
|
|
||
| // Start merge that will conflict | ||
| _, _ = testutil.RunGit(t, dir, "merge", "feature") | ||
|
|
||
| withGitRepo(t, dir, func() { |
There was a problem hiding this comment.
This test intentionally triggers a merge conflict, but it discards the error/output from git merge. Please assert that the merge command failed due to a conflict (e.g., err != nil and output indicates a conflict) so the test fails with a clear reason if the setup does not actually enter a merge state.
| // Start squash merge that will conflict | ||
| _, _ = testutil.RunGit(t, dir, "merge", "--squash", "feature") | ||
|
|
||
| withGitRepo(t, dir, func() { |
There was a problem hiding this comment.
This test intentionally triggers a squash-merge conflict, but it discards the error/output from git merge --squash. Please assert that the command failed due to a conflict so the test reliably verifies the squash-merge detection logic.
| if !isStateValid(state) { | ||
| if err := ClearMergeState(); err == nil { | ||
| fmt.Fprintf(os.Stderr, "Note: Cleared stale merge state from a previous operation\n") | ||
| } |
There was a problem hiding this comment.
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.
Emit a warning to stderr when ClearMergeState fails instead of silently ignoring the error. This helps users understand why stale merge state keeps reappearing (e.g., permission issues). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed round 2 review in b338505. Accepted (1 comment):
Dismissed (2 comments):
|
Add validation to
IsMergeInProgress()so that stale merge state files are automatically detected, cleared, and reported instead of blocking operations with confusing errors likeunknown branch type:(see #87).IsMergeInProgress()previously only checked whethermerge.jsonexisted. It now validates the state against reality before returning true. If validation fails, the state file is cleared and a note is printed to stderr.Validation rules:
BranchType,FullBranchName, andCurrentStepmust be non-emptymerge,update_children) — git must actually be in a merge, rebase, or squash merge statecreate_tag,delete_branch) — the topic branch must still existThree new git state detection functions are added:
IsGitMergeInProgress(checksMERGE_HEAD),IsGitRebaseInProgress(checksrebase-merge/andrebase-apply/), andIsGitSquashMergeInProgress(checksSQUASH_MSGwithoutrebase-merge/, sinceSQUASH_MSGalso appears during interactive rebase squash steps).Resolves #101
Remarks
git merge --squashcreatesSQUASH_MSGbut notMERGE_HEAD, andSQUASH_MSGalso appears during interactive rebase squash stepsIsMergeInProgress()so every caller benefits automaticallyBranchTypeexists in configReview focus:
internal/git/repo.go— three new state detection functionsinternal/mergestate/mergestate.go—isStateValid()and updatedIsMergeInProgress()test/cmd/finish_state_test.go— four new stale state integration teststest/internal/git/repo_test.go— six new unit tests for state detection