Fix merge state not cleared when branch deletion fails#105
Fix merge state not cleared when branch deletion fails#105alexrinass wants to merge 3 commits intomainfrom
Conversation
Moves ClearMergeState() before deleteBranchesIfNeeded() in the delete_branch step of the finish operation. Previously, if branch deletion failed (e.g. remote permission error), the function returned early and merge state was never cleared, leaving the user stuck in a "merge in progress" state despite the merge having completed. By the time the delete_branch step runs, all merges, tags, and child updates are complete. The state file is only needed for conflict recovery, which is no longer possible at this point. Clearing it before deletion ensures cleanup failures don't cause inconsistent repository state. Resolves #97
There was a problem hiding this comment.
Pull request overview
Fixes a finish-state-machine cleanup bug where merge.json could be left behind when branch deletion fails, incorrectly blocking later operations with “merge in progress”.
Changes:
- Clear merge state earlier in the
delete_branchstep so deletion errors don’t prevent state cleanup. - Add a
GitFlowMergeStateExiststestutil helper to assert presence/absence ofmerge.json. - Add an integration test covering the “deletion fails but merge state is cleared” scenario.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
cmd/finish.go |
Moves mergestate.ClearMergeState() before branch deletion in the final finish step. |
test/cmd/finish_retention_test.go |
Adds integration test intended to ensure merge state is cleared even if remote deletion fails. |
test/testutil/testutil.go |
Adds helper to check whether gitflow/state/merge.json exists in a repo/worktree. |
| output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "delete-fail-test") | ||
| // We expect an error from the failed remote branch deletion | ||
| if err == nil { | ||
| t.Log("Note: finish succeeded despite expected remote deletion failure") | ||
| } |
There was a problem hiding this comment.
This test doesn’t currently guarantee that remote branch deletion actually fails: it logs when err == nil, which means the key scenario may not be exercised and the assertion could pass even if deletion succeeded. Tighten the test by requiring a non-nil error (or checking for a specific deletion-failure message / that the remote branch still exists) so it truly validates the regression fix.
| // Make the remote bare repo read-only so git push --delete will fail | ||
| if err := os.Chmod(remoteDir, 0444); err != nil { | ||
| t.Fatalf("Failed to make remote read-only: %v", err) | ||
| } | ||
| defer os.Chmod(remoteDir, 0755) // Restore for cleanup |
There was a problem hiding this comment.
Making the bare remote repo read-only via os.Chmod(remoteDir, 0444) is platform/filesystem dependent and may not reliably cause git push --delete to fail (and can behave differently on Windows). Consider a deterministic, cross-platform mechanism such as setting receive.denyDeletes=true in the bare repo’s config (or installing a pre-receive hook that rejects deletes) to force the deletion failure.
| t.Helper() | ||
| gitDir, err := getGitDirForRepo(dir) | ||
| if err != nil { | ||
| return false |
There was a problem hiding this comment.
GitFlowMergeStateExists returns false when getGitDirForRepo fails, which can create false negatives and let tests pass even when the helper couldn’t actually check for merge.json. Since this is a test assertion helper, it should fail the test on error (e.g., t.Fatalf) or return (bool, error) so callers can handle failures explicitly.
| return false | |
| t.Fatalf("Failed to determine git directory for repo %s: %v", dir, err) |
Use receive.denyDeletes instead of os.Chmod to reliably reject remote branch deletion. Verify the remote branch still exists after finish to confirm the deletion was actually rejected. Fail GitFlowMergeStateExists on git dir errors instead of silently returning false. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed all 3 review comments in fb4708d: Accepted:
|
| // Clear the merge state before branch deletion. By this point all merges, | ||
| // tags, and child updates are complete — the state is only needed for conflict | ||
| // recovery which is no longer possible. Clearing early ensures a failed branch | ||
| // deletion (e.g. remote permission error) doesn't leave stale merge state. | ||
| if err := mergestate.ClearMergeState(); err != nil { | ||
| return &errors.GitError{Operation: "clear merge state", Err: err} | ||
| } |
There was a problem hiding this comment.
Clearing merge state before attempting branch deletion means a deletion error (e.g., transient remote failure) will now return an error without leaving a merge state to resume via finish --continue. Since deleteBranchesIfNeeded currently returns a fatal error on remote deletion failure, consider either (a) making branch deletion failures non-fatal warnings at this final step, or (b) keeping enough state to allow a retry while still guaranteeing state cleanup (e.g., clear state via a defer that always runs on exit).
|
|
||
| // Finish the feature branch — merge should succeed but remote deletion should fail | ||
| output, err = testutil.RunGitFlow(t, dir, "feature", "finish", "delete-fail-test") | ||
| _ = output // finish may or may not return an error depending on how deletion failure is handled |
There was a problem hiding this comment.
The test currently ignores the result of the command under test (RunGitFlow(... finish ...)): err is never asserted and output is intentionally discarded. This makes failures harder to diagnose and can allow unexpected failure modes to slip through. Please assert the expected outcome explicitly (e.g., success, or a specific deletion-related error/exit code) and include output in failure messages.
| _ = output // finish may or may not return an error depending on how deletion failure is handled | |
| if err != nil { | |
| deletionRejected := strings.Contains(output, "denyDeletes") || | |
| strings.Contains(output, "deletion") || | |
| strings.Contains(output, "delete") || | |
| strings.Contains(output, "remote rejected") | |
| if !deletionRejected { | |
| t.Fatalf("Expected feature finish to either succeed or fail due to remote deletion rejection, got err: %v\nOutput: %s", err, output) | |
| } | |
| } |
| // Steps: | ||
| // 1. Sets up a test repository with a remote and initializes git-flow | ||
| // 2. Creates a feature branch with changes and pushes to remote | ||
| // 3. Makes the remote repository read-only so branch deletion will fail |
There was a problem hiding this comment.
The test doc/Steps say the remote is made "read-only", but the code sets receive.denyDeletes=true (which rejects deletes but does not make the repo read-only). Please update the comment to match what the test actually does so the scenario is clear.
| // 3. Makes the remote repository read-only so branch deletion will fail | |
| // 3. Configures the remote repository to reject branch deletions so deletion will fail |
| _, err = os.Stat(filepath.Join(gitDir, "gitflow", "state", "merge.json")) | ||
| return err == nil |
There was a problem hiding this comment.
GitFlowMergeStateExists returns false for any os.Stat error, which can hide unexpected problems (e.g., permission or IO errors). Consider treating only os.IsNotExist(err) as "does not exist" and failing the test on other errors to make issues visible.
| _, err = os.Stat(filepath.Join(gitDir, "gitflow", "state", "merge.json")) | |
| return err == nil | |
| stateFile := filepath.Join(gitDir, "gitflow", "state", "merge.json") | |
| _, err = os.Stat(stateFile) | |
| if err == nil { | |
| return true | |
| } | |
| if os.IsNotExist(err) { | |
| return false | |
| } | |
| t.Fatalf("Failed to stat git-flow merge state file %s: %v", stateFile, err) | |
| return false |
Update test comment to reflect receive.denyDeletes mechanism instead of stale "read-only" wording. Distinguish IsNotExist from permission/IO errors in GitFlowMergeStateExists to surface unexpected failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed round 2 review in c615b3c. Accepted (2 comments):
Dismissed (2 comments):
|
Clear merge state before attempting branch deletion in the finish command's delete_branch step.
Previously,
ClearMergeState()was called afterdeleteBranchesIfNeeded()inhandleDeleteBranchStep(). If branch deletion failed (e.g. remote permission error or network issue), the function returned early and merge state cleanup was skipped. This left a stalemerge.jsonthat blocked future operations with a "merge in progress" error, even though the merge itself had completed successfully. The fix movesClearMergeState()beforedeleteBranchesIfNeeded(), since by the time the delete_branch step runs all merges, tags, and child updates are already complete — the state file is only needed for conflict recovery, which is no longer possible at this point. A newGitFlowMergeStateExiststest helper and integration test verify the fix.Resolves #97
Remarks
Review focus:
cmd/finish.go—ClearMergeState()moved beforedeleteBranchesIfNeeded()inhandleDeleteBranchStep()test/cmd/finish_retention_test.go—TestFinishClearsMergeStateWhenBranchDeletionFailstest/testutil/testutil.go— newGitFlowMergeStateExistshelper