Skip to content

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

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions