DEV Community

pponali
pponali

Posted on

Stop Wrestling with Merge Conflicts: Automate the Whole Workflow

It's 4:47 PM on a Friday. You've been coding all week on a feature you're proud of. You open a PR, and GitHub greets you with the one message that turns your stomach:

"This branch has conflicts that must be resolved."

You already know what's waiting. The pom.xml has diverged again. Someone on main bumped a YAML config. There are three Markdown docs with ugly <<<<<<< HEAD markers scattered through them. You're not shipping today.

Every software developer has lived this moment. And most of us keep solving it the same slow, error-prone, manual way — every single time.

This post is about why that's a problem, and how the /resolve-conflicts Claude Cowork skill fixes it — a first-class Claude skill you simply invoke in natural language, and Claude handles the entire workflow end to end.

🧩 The /resolve-conflicts skill is an installable Claude Cowork skill that automates your entire merge conflict workflow. The source code, shell script, and full documentation are open source on GitHub: github.com/pponali/claude_skills

The Real Pain Points Behind Merge Conflicts

Merge conflicts feel like a Git problem, but they're really a team coordination problem made visible. Here's what actually makes them brutal:

They always hit at the worst time. You finish a feature after days of work, go to merge, and suddenly you're debugging someone else's schema migration inside your application.yml. Context-switching mid-sprint to resolve config conflicts is a massive cognitive tax.

The same files conflict over and over. In any non-trivial project, you'll find the same suspects in your conflict list week after week: pom.xml, docker-compose.yml, *.properties, localization files, OpenAPI specs. These aren't complex logical conflicts — they're mechanical, repetitive, and boring. Yet they still demand your full attention.

Manual resolution is error-prone under pressure. When you're staring at a wall of ======= markers, it's easy to accidentally keep the wrong version, drop a dependency update, or smash together two configs in a way that silently breaks something. There's no guardrail — just you, your eyes, and your mouse.

The feedback loop is slow. You resolve conflicts, push, wait for CI, find out you introduced a bug in the config you just "fixed," fix it again, push again. The cycle can eat hours.

It doesn't scale with team size. On a small team, conflicts are annoying. On a team of 20+ engineers all merging into main frequently, they're a daily tax on every developer's time.

The Traditional Approach (And Why It Falls Short)

The standard workflow most of us follow looks like this:

git fetch origin
git merge origin/main
# ... open every conflicted file in VS Code ...
# ... manually choose ours / theirs / both ...
git add .
git commit -m "Merge branch 'main' into feature/my-thing"
git push
Enter fullscreen mode Exit fullscreen mode

This works, but it has serious problems:

  • No consistency. Different developers make different choices for the same file types. One dev always keeps pom.xml from main; another always keeps the branch version. The merge history becomes unpredictable.
  • No documentation. The merge commit rarely explains why you resolved conflicts the way you did. Future you (and your teammates) have no idea.
  • No scalability. Every conflict requires a human to sit down and make individual decisions, even for files where the right answer is always the same.

Enter /resolve-conflicts: A Claude Cowork Skill That Does It All

The /resolve-conflicts skill is a Claude Cowork skill — meaning you don't run commands yourself. You just tell Claude what you want in plain English:

  • "Merge main into my branch and resolve conflicts"
  • "My PR shows conflicts — fix them"
  • "Sync my feature branch with origin/main"

Claude takes it from there. Under the hood it drives .claude/scripts/resolve-conflicts.sh — a shell script with codified heuristics for every file type in your project. The skill + script together are fully open source:

📦 github.com/pponali/claude_skills

The core idea: most conflicts in most projects follow predictable patterns. Once those patterns are encoded as a Claude skill, you never have to think about them again — just describe the problem and let Claude handle it.

Here's what Claude does behind the scenes when you invoke the skill:

# Step 1 — Claude runs a dry-run first and summarises what it found
.claude/scripts/resolve-conflicts.sh origin/main true

# Step 2 — After your confirmation, Claude runs for real
.claude/scripts/resolve-conflicts.sh origin/main false
Enter fullscreen mode Exit fullscreen mode

Claude handles everything: stashing uncommitted changes, merging, resolving each conflict using the right heuristic, staging, creating an auditable commit, pushing, and optionally checking PR status via the gh CLI. You just review and confirm.

How the Resolution Heuristics Work

The script doesn't guess — it applies explicit, per-file-type rules:

File pattern Strategy Rationale
pom.xml, */pom.xml Keep branch (--ours) Feature branch usually holds newer or WIP dependencies
*.properties Keep branch Branch has local/feature-specific config values
*.yaml / *.yml Keep branch Same reasoning as properties
docker-compose.* Keep branch Preserves the current service topology
*.md Keep both (concatenated) Docs from both sides are usually additive, not contradictory
*.java Keep branch Active feature work lives on the branch
*.js, *.ts, *.tsx Keep branch Same as Java
anything else Keep branch + warning Safe default; flagged for manual review

"Keep branch" means the HEAD side of the merge — your feature branch. The strategy is opinionated by design. If your project has different conventions, you update the script once, and every future merge follows the new rules automatically.

The Markdown strategy is particularly clever: instead of picking one side, it concatenates both. Documentation is almost always additive — two sections from different branches are usually both worth keeping, so the merge result includes both and a human can clean it up later without losing information.

The Dry-Run Is Your Best Friend

Before running for real, always do a dry run:

.claude/scripts/resolve-conflicts.sh origin/main true
Enter fullscreen mode Exit fullscreen mode

The output tells you:

  • Which files have conflicts
  • Which rule the script will apply to each one
  • Any files in the "unknown" bucket (these get --ours by default and a warning logged)

This is the moment to catch surprises. If main bumped a critical version in pom.xml that your branch must absorb, you'll see it here. You can let the script handle everything else and manually fix just that one file afterwards.

Verify, Don't Assume

After the script runs, always check:

# Should show your merge commit
git log --oneline -3

# MUST be empty — any output here means unresolved conflicts
git diff --name-only --diff-filter=U

# Quick overview of what changed
git show HEAD --stat
Enter fullscreen mode Exit fullscreen mode

If your project has a fast smoke test — mvn -q -DskipTests verify, npm run typecheck, docker compose config — run it now. Heuristic resolutions are fast but not infallible.

If you're using the gh CLI:

gh pr view <number> --json mergeable,mergeStateStatus
Enter fullscreen mode Exit fullscreen mode

Note: GitHub takes 1–2 minutes to recompute mergeability after a push. Don't panic if it shows conflicts for a moment after you push.

When NOT to Use the Script

The script is great for mechanical conflicts, but there are cases where you need a human in the loop:

Core business logic in .java / .ts / .js files where both sides have unique, non-overlapping changes. The script will keep the branch version and silently drop main's changes. If both sides implemented different parts of the same feature, you need to merge them manually.

A critical dependency update in pom.xml that your branch must consume. The script keeps your branch version. After it runs, manually edit the file, git add, and git commit --amend --no-edit.

Files in the "unknown" bucket (*.sql, *.proto, *.kt, etc.). The script flags these but defaults to --ours. Open them, check if main's changes matter, resolve manually, then fold into the merge commit:

# After manually editing the file:
git add path/to/file.sql
git commit --amend --no-edit
git push --force-with-lease origin your-branch
Enter fullscreen mode Exit fullscreen mode

The pattern that works best: let the script handle the 80% of mechanical conflicts, then manually patch the 20% that need actual reasoning. You get speed and correctness.

Real-World Payoff

Once your team adopts this workflow, a few things change:

Merge commits become consistent and auditable. The commit message documents which strategy was applied to which files. Six months from now, you can look at a merge commit and understand exactly what happened.

Conflict resolution stops being a senior-developer task. The rules are encoded in a script, not in someone's head. A junior developer can merge safely without needing to ask "should I keep ours or theirs for the YAML?".

Friday afternoon PRs stop being feared. When conflict resolution is one command instead of a half-hour of careful manual editing, you stop dreading the merge.

Getting Started: Install the Claude Skill

Everything — the skill definition, the shell script, and full documentation — is in the open source repo:

📦 github.com/pponali/claude_skills

# Add the script to your project
mkdir -p .claude/scripts
curl -o .claude/scripts/resolve-conflicts.sh \
  https://raw.githubusercontent.com/pponali/claude_skills/master/.claude/scripts/resolve-conflicts.sh
chmod +x .claude/scripts/resolve-conflicts.sh
Enter fullscreen mode Exit fullscreen mode

Once installed, just open Claude in Cowork mode and describe the problem. Claude will:

  1. Run pre-flight checks (detects if you're already mid-merge)
  2. Do a dry run and summarise exactly which files it will touch and how
  3. Ask for your confirmation before committing anything
  4. Merge, resolve, commit, push — and report back

You're always in control. Claude handles the mechanical parts, you handle the judgment calls.

Running the script directly (without Claude) is also supported:

# Dry run first — always
.claude/scripts/resolve-conflicts.sh origin/main true

# Then for real
.claude/scripts/resolve-conflicts.sh origin/main false

# Merge a different branch
.claude/scripts/resolve-conflicts.sh origin/feat/payment false
Enter fullscreen mode Exit fullscreen mode

The Full Script

Drop this into .claude/scripts/resolve-conflicts.sh in your repo and make it executable (chmod +x). It's self-contained — no external dependencies beyond bash, git, and optionally the gh CLI for PR status checks.

#!/usr/bin/env bash
# =============================================================================
# resolve-conflicts.sh
# Automated merge-conflict resolution with per-file-type heuristics.
#
# Usage:
#   .claude/scripts/resolve-conflicts.sh [target-branch] [dry-run]
#
# Arguments:
#   target-branch  Branch to merge in. Default: origin/main
#   dry-run        true|false. Default: false
#
# Examples:
#   .claude/scripts/resolve-conflicts.sh                          # merge origin/main, for real
#   .claude/scripts/resolve-conflicts.sh origin/main true        # dry-run first
#   .claude/scripts/resolve-conflicts.sh origin/feat/payment false
# =============================================================================

set -euo pipefail

TARGET_BRANCH="${1:-origin/main}"
DRY_RUN="${2:-false}"

# Colour helpers
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'
info()    { echo -e "${CYAN}[resolve-conflicts]${NC} $*"; }
success() { echo -e "${GREEN}[resolve-conflicts]${NC} $*"; }
warn()    { echo -e "${YELLOW}[resolve-conflicts] WARN:${NC} $*"; }
error()   { echo -e "${RED}[resolve-conflicts] ERROR:${NC} $*" >&2; }

# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
if git rev-parse --verify MERGE_HEAD &>/dev/null; then
  error "Repo is already mid-merge (MERGE_HEAD exists)."
  error "Run 'git merge --abort' to cancel the previous merge, then retry."
  exit 1
fi

CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
info "Current branch : $CURRENT_BRANCH"
info "Target branch  : $TARGET_BRANCH"
info "Dry run        : $DRY_RUN"

info "Fetching origin..."
git fetch origin --quiet

# ---------------------------------------------------------------------------
# Stash any uncommitted changes
# ---------------------------------------------------------------------------
STASHED=false
if ! git diff --quiet || ! git diff --cached --quiet; then
  info "Stashing local changes..."
  git stash push -m "resolve-conflicts auto-stash $(date +%s)"
  STASHED=true
fi

# ---------------------------------------------------------------------------
# Attempt the merge
# ---------------------------------------------------------------------------
info "Merging $TARGET_BRANCH into $CURRENT_BRANCH..."
if git merge "$TARGET_BRANCH" --no-edit --no-commit 2>/dev/null; then
  success "Merge completed with no conflicts. Committing..."
  if [ "$DRY_RUN" = "false" ]; then
    git commit --no-edit -m "Merge $TARGET_BRANCH into $CURRENT_BRANCH (no conflicts)"
    git push origin "$CURRENT_BRANCH"
    success "Pushed."
  else
    git merge --abort 2>/dev/null || true
    info "[DRY RUN] No conflicts — merge would succeed cleanly."
  fi
  $STASHED && git stash pop || true
  exit 0
fi

CONFLICTED_FILES="$(git diff --name-only --diff-filter=U)"

if [ -z "$CONFLICTED_FILES" ]; then
  warn "Merge stopped but no conflict markers found. Check git status."
  git merge --abort 2>/dev/null || true
  $STASHED && git stash pop || true
  exit 1
fi

info "Conflicted files:"
echo "$CONFLICTED_FILES" | while read -r f; do echo "  - $f"; done

# ---------------------------------------------------------------------------
# Resolution heuristics
# ---------------------------------------------------------------------------
resolve_file() {
  local file="$1"
  local strategy="" reason=""

  case "$file" in
    pom.xml|*/pom.xml)
      strategy="ours";  reason="pom.xml — keep branch (feature deps)" ;;
    *.properties)
      strategy="ours";  reason="*.properties — keep branch (feature config)" ;;
    *.yaml|*.yml)
      strategy="ours";  reason="*.yaml/yml — keep branch (feature config)" ;;
    docker-compose*|*/docker-compose*)
      strategy="ours";  reason="docker-compose — keep branch (feature topology)" ;;
    *.md)
      strategy="union"; reason="*.md — keep both (documentation is additive)" ;;
    *.java)
      strategy="ours";  reason="*.java — keep branch (active feature work)" ;;
    *.js|*.ts|*.tsx|*.jsx)
      strategy="ours";  reason="*.js/ts — keep branch (active feature work)" ;;
    *)
      strategy="ours"
      reason="unknown type — defaulting to branch; MANUAL REVIEW RECOMMENDED"
      warn "No rule for: $file — keeping branch version. Please verify manually." ;;
  esac

  if [ "$DRY_RUN" = "true" ]; then
    info "[DRY RUN] $file$strategy  ($reason)"
    return
  fi

  info "Resolving: $file$strategy  ($reason)"

  if [ "$strategy" = "ours" ]; then
    git checkout --ours -- "$file"
    git add "$file"
  elif [ "$strategy" = "union" ]; then
    local ours theirs
    ours="$(git show :2:"$file" 2>/dev/null || true)"
    theirs="$(git show :3:"$file" 2>/dev/null || echo "")"
    { echo "$ours"; echo ""; echo "---"; echo "<!-- merged from $TARGET_BRANCH -->"; echo ""; echo "$theirs"; } > "$file"
    git add "$file"
  fi
}

while IFS= read -r file; do
  [ -n "$file" ] && resolve_file "$file"
done <<< "$CONFLICTED_FILES"

# ---------------------------------------------------------------------------
# Commit and push
# ---------------------------------------------------------------------------
if [ "$DRY_RUN" = "true" ]; then
  info "[DRY RUN] Complete. No files were changed."
  git merge --abort 2>/dev/null || true
  $STASHED && git stash pop || true
  exit 0
fi

STILL_CONFLICTED="$(git diff --name-only --diff-filter=U 2>/dev/null || true)"
if [ -n "$STILL_CONFLICTED" ]; then
  error "Some files are still unresolved — resolve manually, 'git add', then 'git commit'."
  echo "$STILL_CONFLICTED"
  $STASHED && git stash pop || true
  exit 1
fi

COMMIT_MSG="Merge $TARGET_BRANCH into $CURRENT_BRANCH (auto-resolved)

Heuristics applied:
$(echo "$CONFLICTED_FILES" | while read -r f; do echo "  - $f"; done)

Generated by resolve-conflicts.sh"

git commit -m "$COMMIT_MSG"
success "Merge commit created."

git push origin "$CURRENT_BRANCH"
success "Pushed to origin/$CURRENT_BRANCH."

if $STASHED; then
  info "Restoring stashed changes..."
  git stash pop || warn "Stash pop failed — run 'git stash list' to inspect."
fi

if command -v gh &>/dev/null; then
  info "Checking PR status (GitHub may take ~2 min to recompute)..."
  gh pr view --json mergeable,mergeStateStatus 2>/dev/null \
    | python3 -c "import json,sys; d=json.load(sys.stdin); print(f'  mergeable={d[\"mergeable\"]}  state={d[\"mergeStateStatus\"]}')" \
    || true
fi

success "Done. Run 'git log --oneline -3' to verify the merge commit."
Enter fullscreen mode Exit fullscreen mode

Save it, make it executable, and you're ready:

mkdir -p .claude/scripts
# paste the script above into .claude/scripts/resolve-conflicts.sh
chmod +x .claude/scripts/resolve-conflicts.sh
Enter fullscreen mode Exit fullscreen mode

Merge conflicts aren't going away. As long as multiple people work on the same codebase, there will be divergence. But the amount of manual, repetitive work they require is entirely within your control. Codify your team's conventions, automate the mechanics, and save the human judgment for the conflicts that actually need it.

Your future Friday self will thank you.

Top comments (0)