Development Playbook

Dry-run the merge before you review: a short script for the branches your parallel agents leave behind

Four agents, four branches, one afternoon. A git merge-tree script tells you which pairs collide, which order lands cleanly and whether the tests still pass on the result, without touching a branch. Ten minutes to set up.

A concrete tunnel where four thin blue light streaks from the corners merge into one bright streak at the centre

Four worktree sessions finish within a few minutes of each other. Each reports success, each has a green test run, and each left a branch behind. Now the real work starts. Merge them in the wrong order and the second merge stops on a conflict in a file the third branch also rewrote. Merge them in a lucky order and everything lands cleanly, then the test suite fails on code nobody wrote: two agents each did something sensible in isolation, and the sum doesn’t compile.

Reading four diffs in full to work out which of these you’ve got is the slow way. Git can answer most of it first. git merge-tree performs a real merge in memory and reports the outcome without touching a branch, an index or a working directory.

This Playbook turns that into one script. Point it at the branches your agents left, and it prints which pairs collide, a merge order that avoids the collisions it can, and the result of your test command on the combined code, run in a throwaway worktree. It takes about ten minutes to set up and needs git and Python 3. It’s agent-agnostic: any branch works, including the worktree-<name> branches that claude --worktree <name> creates.

What the preflight answers

Before the script, the questions it’s built around. They’re the ones a reviewer ends up asking anyway, in a worse order.

Which pairs of branches conflict textually? A pairwise dry run settles that in a second per pair, and it names the files.

Which pairs edit the same file but merge cleanly? That’s the grey zone. Git is happy, a human should glance at it.

If the clean branches go in first, does the integrated result still pass the tests? Textual cleanliness says nothing about whether the code still means what it did. Only running something answers that.

Which branches does that leave for a person? Those are the ones where two agents disagreed about the same lines, and picking a winner is a design decision, not a merge chore.

The earlier piece on running parallel sessions covers starting the sessions and choosing tasks that shouldn’t overlap. This one picks up after they finish.

How merge-tree behaves

The form the script uses is git merge-tree --write-tree. Git’s documentation gives the contract: exit status 0 for a clean merge, 1 when there are conflicts, and anything else means the merge couldn’t run, with unspecified output. A clean merge prints one line, the object ID of the resulting tree. A conflicted one prints that tree ID first, then the conflicted files, then informational messages.

Two flags matter here. --name-only reduces the conflicted-file list to plain paths, without mode, object ID and stage. --no-messages drops the “Auto-merging” and CONFLICT chatter, which a script doesn’t want.

The documentation also carries a warning that’s easy to miss and expensive to ignore: don’t read an empty conflict list as a clean merge, check the exit status. The script below does exactly that, and treats any exit code other than 0 or 1 as a hard stop.

One more property makes the sequential part possible. The tree that merge-tree returns can be wrapped in a commit with git commit-tree, which gives the next dry run a real commit to merge against. Nothing is written to a branch, so nothing in your repository moves. The only leftovers are unreferenced objects, which git’s normal garbage collection removes.

The script

Save this as scripts/agent-merge-check.py in the repository, or anywhere on your path. It uses the standard library only.

#!/usr/bin/env python3
"""Dry-run the merge of several agent branches. Moves no branch and edits no working tree."""
import argparse
import itertools
import subprocess
import sys
import tempfile


def git(*args, check=True):
    r = subprocess.run(["git", *args], capture_output=True, text=True)
    if check and r.returncode != 0:
        sys.exit(f"git {' '.join(args)} failed: {r.stderr.strip()}")
    return r


def changed_files(base, branch):
    out = git("diff", "--name-only", f"{base}...{branch}").stdout
    return set(out.split())


def merge_tree(a, b):
    """Return (tree, conflicted_files, clean). Exit 0 is clean, 1 is conflicts, anything else is an error."""
    r = git("merge-tree", "--write-tree", "--name-only", "--no-messages", a, b, check=False)
    if r.returncode not in (0, 1):
        sys.exit(f"merge-tree {a} {b} could not run: {r.stderr.strip()}")
    lines = r.stdout.split("\n")
    tree = lines[0]
    files = [l for l in lines[1:] if l] if r.returncode == 1 else []
    return tree, files, r.returncode == 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base", default="main")
    ap.add_argument("--test", help="command to run on the integrated result, in a throwaway worktree")
    ap.add_argument("branches", nargs="*")
    args = ap.parse_args()

    branches = args.branches or git(
        "for-each-ref", "--format=%(refname:short)", "refs/heads/worktree-*"
    ).stdout.split()
    if len(branches) < 2:
        sys.exit("need at least two branches")

    touched = {b: changed_files(args.base, b) for b in branches}
    conflicts = {b: 0 for b in branches}

    print("Pairwise\n--------")
    for a, b in itertools.combinations(branches, 2):
        _, files, clean = merge_tree(a, b)
        shared = sorted(touched[a] & touched[b])
        if not clean:
            conflicts[a] += 1
            conflicts[b] += 1
            print(f"CONFLICT  {a} + {b}: {', '.join(files)}")
        elif shared:
            print(f"shared    {a} + {b}: merges clean, both edit {', '.join(shared)}")
        else:
            print(f"disjoint  {a} + {b}")

    order = sorted(branches, key=lambda b: (conflicts[b], len(touched[b])))
    print("\nIntegration order\n-----------------")
    acc = git("rev-parse", args.base).stdout.strip()
    taken, left_out = [], []
    for b in order:
        tree, files, clean = merge_tree(acc, b)
        if clean:
            acc = git("commit-tree", tree, "-p", acc, "-p", b, "-m", f"dry run: {b}").stdout.strip()
            taken.append(b)
            print(f"ok        {b}")
        else:
            left_out.append(b)
            print(f"SKIPPED   {b}: conflicts with the branches already taken in {', '.join(files)}")

    if args.test and taken:
        wt = tempfile.mkdtemp(prefix="agent-merge-")
        git("worktree", "add", "--detach", wt, acc)
        try:
            print(f"\nRunning in throwaway worktree: {args.test}\n")
            rc = subprocess.run(args.test, shell=True, cwd=wt).returncode
            print(f"\ntest command exited {rc}")
        finally:
            git("worktree", "remove", "--force", wt)
    print(f"\nIntegrated {len(taken)} of {len(branches)}; left for a human: {', '.join(left_out) or 'none'}")


if __name__ == "__main__":
    main()

Three decisions in there are worth knowing about, because they’re the ones you’ll want to change.

The merge order is a heuristic: branches with the fewest conflicts go first, ties broken by the number of files touched. It isn’t optimal. It just keeps the quiet branches in the result and pushes the contentious ones to the end, where a conflict costs the least.

A branch that conflicts with what’s already been taken is skipped, not resolved. The script never guesses. It reports the files and leaves the choice to you.

The test command runs through the shell in a detached worktree at the integrated commit, then the worktree is removed whatever happens. It starts with a clean checkout, so dependencies aren’t there. Your command has to install them.

The demo repository

The numbers below come from a deliberately small repository: a three-file TypeScript cart (pricing.ts, format.ts, cart.ts) with a Vitest suite. Five branches stand in for agents that each got a one-line task:

  • worktree-rename-total renames totalCents to subtotalCents in pricing.ts and its caller.
  • worktree-discounts adds a discountPct parameter to totalCents, which rewrites the body of the same function.
  • worktree-item-note adds an optional note field to the Item interface, a few lines above that function.
  • worktree-receipt adds a new src/receipt.ts that calls totalCents, with its own test.
  • worktree-format-currency gives formatPrice a currency symbol parameter, in format.ts.

Each branch passes the tests on its own. Run the script with the branches left on their default names, and a test command that installs and runs Vitest:

python3 scripts/agent-merge-check.py --test "npm ci --silent && npx vitest run"

It prints:

Pairwise
--------
disjoint  worktree-discounts + worktree-format-currency
shared    worktree-discounts + worktree-item-note: merges clean, both edit src/pricing.ts
disjoint  worktree-discounts + worktree-receipt
CONFLICT  worktree-discounts + worktree-rename-total: src/pricing.ts
disjoint  worktree-format-currency + worktree-item-note
disjoint  worktree-format-currency + worktree-receipt
disjoint  worktree-format-currency + worktree-rename-total
disjoint  worktree-item-note + worktree-receipt
shared    worktree-item-note + worktree-rename-total: merges clean, both edit src/pricing.ts
disjoint  worktree-receipt + worktree-rename-total

Integration order
-----------------
ok        worktree-format-currency
ok        worktree-item-note
ok        worktree-receipt
ok        worktree-discounts
SKIPPED   worktree-rename-total: conflicts with the branches already taken in src/pricing.ts

Running in throwaway worktree: npm ci --silent && npx vitest run

 Test Files  2 passed (2)
      Tests  2 passed (2)

test command exited 0

Integrated 4 of 5; left for a human: worktree-rename-total

The run header and timing lines are trimmed from the Vitest output. Everything else is as printed.

Read it top to bottom. Exactly one pair collides: the rename and the discount change both rewrite totalCents. Two pairs are “shared”: they edit pricing.ts in different regions, git merges them without complaint, and the script flags it so someone looks. Four branches combine and pass the tests. The fifth is left for a person, and the script has already said which file to open.

That’s the information a reviewer wants before reading any diff. It also changes what to review: worktree-rename-total needs a decision, the two “shared” pairs deserve a skim, and the other branches are ordinary pull requests.

The case git can’t see

Now the part that makes the --test flag worth having. Run the script on just two branches:

python3 scripts/agent-merge-check.py --test "npm ci --silent && npx vitest run" \
  worktree-receipt worktree-rename-total
Pairwise
--------
disjoint  worktree-receipt + worktree-rename-total

Integration order
-----------------
ok        worktree-receipt
ok        worktree-rename-total

Running in throwaway worktree: npm ci --silent && npx vitest run

 ❯ test/receipt.test.ts (1 test | 1 failed) 5ms
   × prints a receipt 4ms
 FAIL  test/receipt.test.ts > prints a receipt
TypeError: totalCents is not a function
 ❯ receipt src/receipt.ts:6:42
      6|   return [...lines, `Total ${formatPrice(totalCents(items))}`].join("\…
       |                                          ^

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 1 passed (2)

test command exited 1

Integrated 2 of 2; left for a human: none

Disjoint files, a clean merge, and a broken build. One branch renamed a function while the other added a new file that calls the old name. Neither agent could have known. Git saw no overlap because there isn’t any, textually.

This is the whole argument for the third stage. A conflict matrix catches collisions. It can’t catch two changes that disagree about meaning, and those are the ones that reach main when a reviewer trusts a clean merge. If your test command includes a type check, many of them fail loudly here and not in production. The demo above reached a runtime TypeError because Vitest transpiles TypeScript without type checking, so adding npx tsc --noEmit to the test command would catch it a step earlier.

The caveat that follows from it: the tests only catch what they cover. A semantic collision in untested code passes this check just as it would pass any other.

Make it yours

Three variations cover most setups.

Other branch sources. The script takes any refs, so remote branches work: fetch them, then pass origin/feature-a origin/feature-b. For pull requests from teammates or from a cloud agent, that makes it a preflight for a release train. Pass --base origin/main if the local main has drifted.

Other stacks. The --test argument is a shell command, so swap it freely. For a .NET solution, dotnet test after a dotnet restore does the same job. For a monorepo, run only the affected package’s tests to keep the dry run quick. A cheap compile step in front of the tests, npx tsc --noEmit or dotnet build, is worth including because it fails faster and more clearly than a runtime error.

Name filter. The default glob, refs/heads/worktree-*, matches what claude --worktree <name> creates, per the Claude Code worktree docs. If your sessions use another naming scheme, change that one string, or pass the branches explicitly.

Skipped branches

A skipped branch is where an agent is useful again, because now the conflict is narrow and named. This prompt is a starting point, and it wasn’t run:

worktree-rename-total conflicts with worktree-discounts in src/pricing.ts.
Merge the integration branch into worktree-rename-total, resolve the conflict
so that both changes survive (the rename and the discountPct parameter), and
run the full test suite. Do not touch any file the conflict report didn't list.

Review that result the way you’d review any conflict resolution, by reading the resolved hunks, since a model resolving a conflict is choosing between two intentions. Ask which branch should win before asking an agent to merge them.

What goes wrong

The dry run isn’t the real merge. The script merges pairs and a greedy chain, and your actual merge order may differ. If you merge in a different order, or squash one of the branches, rerun it. Git’s documentation also warns that --merge-base can give results unlike git merge when there’s more than one merge base. The script doesn’t use that flag, so the default behaviour applies, but long-lived branches with criss-cross history are where surprises hide.

Textual conflicts depend on how the agent edited. An agent that reformats a file while it works turns a small change into a whole-file diff, and the conflict matrix lights up everywhere. If that’s your noise, the fix is upstream: run the formatter in a pre-commit step so formatting never mixes with logic.

Tests run with your credentials. The test command is executed by your shell inside a checkout of unreviewed code. If a branch’s test setup does something unpleasant, this runs it. That’s the same risk as running tests on any branch, and the same rule applies: don’t point the script at branches you wouldn’t check out.

Cost scales with pairs. Ten branches means 45 pairwise merges. Each is fast on a normal repository, but on one with a very large tree you’ll notice. Cap the number of parallel sessions or drop the pairwise section if you only want the chain and the tests.

Don’t bother with this if two sessions are all you run, and they touch different directories. A quick git merge and a test run give you the same answer in less time. The script pays off from three or four branches, or whenever the tasks were loosely scoped.

First 15 minutes

  1. Save the script and run it with no arguments in a repository where you have two or more worktree-* branches. If you have none, create two with claude --worktree a and claude --worktree b and give each a small task.
  2. Add your real test command with --test, and include a type check or build step first. Remember the throwaway worktree starts without node_modules or a restored package cache.
  3. Read the pairwise section before you open a single diff. Anything marked CONFLICT or shared gets read first.
  4. Merge in the order the script printed, and run the full suite once more after the last merge.
  5. Clean up: git worktree list should show only the main checkout, and git branch -d removes the branches you merged.

Once it’s useful, add one line to your parallel-agent prompt or CLAUDE.md: agents commit to a named branch and never merge. The script then becomes the only place where branches meet.