Summary
When git flow feature finish encounters a merge conflict and the user manually resolves+commits the conflict (bypassing --continue), MERGE_HEAD is cleared by git but the merge.json state file remains. Any subsequent call to --abort then fails because git merge --abort returns exit code 128 (no MERGE_HEAD), causing handleAbort() to return early — ClearMergeState() is never called. The repository is permanently stuck until the state file is deleted manually.
Steps to Reproduce
# 1. Init repo with github preset (feature parent = main)
mkdir /tmp/repro && cd /tmp/repro
git init && git config user.email "x@x.com" && git config user.name "X"
echo "base" > file.txt && git add . && git commit -m "initial"
git flow init --preset github
# 2. Create feature branch and make changes
git flow feature start conflict-feature
echo "feature content" > file.txt && git add . && git commit -m "feat: change"
# 3. Create a diverging commit on main
git checkout main
echo "main content" > file.txt && git add . && git commit -m "chore: diverge"
git checkout feature/conflict-feature
# 4. Trigger feature finish → merge conflict → state file is written
git flow feature finish conflict-feature
# Error: there are still unresolved conflicts. Resolve them and try again
# .git/gitflow/state/merge.json is now written (currentStep: "merge")
# 5. User manually resolves and commits (bypassing --continue)
echo "resolved" > file.txt && git add file.txt
git commit -m "manually resolved"
# MERGE_HEAD is now GONE; merge.json still present
# 6. Try --abort → FAILS (git merge --abort returns exit 128)
git flow feature finish --abort conflict-feature
# Error: failed to abort merge: failed to abort merge: exit status 128
# merge.json is STILL present — ClearMergeState() was never called
# 7. All future feature finish calls are now BLOCKED
git flow feature start another-feature
echo "new" > another.txt && git add . && git commit -m "feat"
git flow feature finish another-feature
# Error: a merge is already in progress for branch 'feature/conflict-feature'. Use --continue or --abort
Workaround: Manually delete the state file:
rm .git/gitflow/state/merge.json
Root Cause
In cmd/finish.go, handleAbort() calls git.MergeAbort() first. If that fails (e.g., no active merge, exit code 128), the function returns an error immediately — mergestate.ClearMergeState() on line ~458 is never reached:
func handleAbort(state *mergestate.MergeState) error {
var err error
switch state.MergeStrategy {
case strategyMerge:
err = git.MergeAbort() // returns exit 128 when no MERGE_HEAD
...
}
if err != nil {
return &errors.GitError{...} // ← early return; state file NOT cleared
}
// ...
mergestate.ClearMergeState() // ← never reached
return nil
}
Additionally, the state file uses a hardcoded relative path .git/gitflow/state/merge.json (in internal/mergestate/mergestate.go:11), which is resolved relative to the current working directory. This works correctly in regular repos and submodules (where .git is a gitfile pointing to the real git dir), but the hardcoded path makes it sensitive to where the binary is invoked from.
Expected Behavior
--abort should clear the state file even when git merge --abort fails (or when there is no active git-level merge to abort). The semantic intent of --abort is "abandon this git-flow operation and clean up state", which should succeed regardless of the underlying git merge status.
Suggested Fix
handleAbort() should call ClearMergeState() unconditionally (or at minimum when git merge --abort fails with "no merge in progress"):
func handleAbort(state *mergestate.MergeState) error {
var err error
switch state.MergeStrategy {
case strategyMerge:
err = git.MergeAbort()
case strategyRebase:
err = git.RebaseAbort()
default:
err = git.MergeAbort()
}
// Only treat it as a real error if git reported something other than
// "no merge/rebase in progress" (exit 128 with that message is expected
// when the user already committed the resolution manually).
if err != nil && !isNoMergeInProgressError(err) {
return &errors.GitError{Operation: "abort merge", Err: err}
}
if err := git.Checkout(state.FullBranchName); err != nil {
return &errors.GitError{...}
}
// Always clear state, even if git-level abort was a no-op
return mergestate.ClearMergeState()
}
Environment
- git-flow-next version: 1.1.0 (Homebrew)
- macOS arm64
- Reproduced with
--preset github (feature parent = main, no develop branch)
- Also reproduced in a git submodule context (identical behaviour)
Reproduction Script
A self-contained shell script to reproduce this locally:
#!/usr/bin/env bash
set -e
REPRO_DIR="/tmp/gf-repro-$(date +%s)"
mkdir -p "$REPRO_DIR" && cd "$REPRO_DIR"
git init -q
git config user.email "repro@test.local" && git config user.name "Repro Test"
echo "initial" > file.txt && git add . && git commit -q -m "initial commit"
git flow init --preset github 2>&1 | tail -2
git flow feature start conflict-feature 2>&1 | head -1
echo "feature content" > file.txt && git add . && git commit -q -m "feat"
git checkout -q main
echo "main content" > file.txt && git add . && git commit -q -m "chore: diverge"
git checkout -q feature/conflict-feature
echo "--- Step 1: trigger conflict ---"
git flow feature finish conflict-feature 2>&1 | grep -E "conflict|Error" || true
echo "State file: $(ls .git/gitflow/state/merge.json 2>/dev/null && echo YES || echo NO)"
echo "--- Step 2: manual resolve + commit (bypass --continue) ---"
echo "resolved" > file.txt && git add file.txt && git commit -q -m "manual resolve"
echo "MERGE_HEAD: $(cat .git/MERGE_HEAD 2>/dev/null || echo GONE)"
echo "--- Step 3: --abort should clean up state (BUG: it doesn't) ---"
git flow feature finish --abort conflict-feature 2>&1 || true
echo "State file after --abort: $(ls .git/gitflow/state/merge.json 2>/dev/null && echo STILL THERE (BUG) || echo gone)"
echo "--- Step 4: new feature finish is blocked ---"
git flow feature start another-feature 2>&1 | head -1
echo "x" > x.txt && git add . && git commit -q -m "feat"
git flow feature finish another-feature 2>&1 | grep -E "Error|blocked|progress" || true
Summary
When
git flow feature finishencounters a merge conflict and the user manually resolves+commits the conflict (bypassing--continue), MERGE_HEAD is cleared by git but themerge.jsonstate file remains. Any subsequent call to--abortthen fails becausegit merge --abortreturns exit code 128 (no MERGE_HEAD), causinghandleAbort()to return early —ClearMergeState()is never called. The repository is permanently stuck until the state file is deleted manually.Steps to Reproduce
Workaround: Manually delete the state file:
Root Cause
In
cmd/finish.go,handleAbort()callsgit.MergeAbort()first. If that fails (e.g., no active merge, exit code 128), the function returns an error immediately —mergestate.ClearMergeState()on line ~458 is never reached:Additionally, the state file uses a hardcoded relative path
.git/gitflow/state/merge.json(ininternal/mergestate/mergestate.go:11), which is resolved relative to the current working directory. This works correctly in regular repos and submodules (where.gitis a gitfile pointing to the real git dir), but the hardcoded path makes it sensitive to where the binary is invoked from.Expected Behavior
--abortshould clear the state file even whengit merge --abortfails (or when there is no active git-level merge to abort). The semantic intent of--abortis "abandon this git-flow operation and clean up state", which should succeed regardless of the underlying git merge status.Suggested Fix
handleAbort()should callClearMergeState()unconditionally (or at minimum whengit merge --abortfails with "no merge in progress"):Environment
--preset github(feature parent = main, no develop branch)Reproduction Script
A self-contained shell script to reproduce this locally: