Skip to content

bug: stale merge.json blocks all future feature finish when --abort is called without MERGE_HEAD #110

@weigosw

Description

@weigosw

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions