A clean merge can still lose your code.
Git forensics that audits merges for hunks that went in on a branch and silently never came out: code dropped during conflict resolution that no test caught and no one noticed.
When two branches merge, git resolves what it can and a human (or a merge queue) resolves the rest. Sometimes the resolution quietly drops a hunk: a function, a guard clause, a bug fix that one side added and the recorded merge simply does not contain. The diff against either parent looks reasonable. The build passes. The code is just gone, and it stays gone until someone notices the feature it implemented stopped working.
casualty finds these. For each merge in a range it re-runs the merge
mechanically using git's own remerge primitive, then compares what that re-merge
preserved against what the recorded merge actually committed. Anything a parent
contributed that survived the re-merge but is absent from the commit, and was
never reintroduced later, is a casualty: reported with the merge, the
file and line range, the lost content, and which side it came from.
Two engineers, one file. One adds a fraud check inside charge(). The other,
on main, changes refund(). The regions do not overlap, so git merges them
cleanly, no conflict. But the merge was committed from a tree that has the
refund change and is missing the fraud check. No conflict markers, no failed
build, nothing to notice. Charges stop being screened.
$ casualty 09dba28..HEAD
casualty · 09dba28..HEAD
3 commit(s) scanned · 1 merge(s) re-merged
9a69c6d Merge fraud-check into main
parents 915e8c6 + 7c56ad1
CLEAR DROP payments.py:4-5 from second parent (the branch you merged in)
| if fraud.is_suspicious(customer, amount):
| raise FraudError("charge blocked by fraud check")
Summary: 1 casualty across 1 merge(s) (1 clear drop(s), 0 ambiguous).The merge had no conflict at all (that is exactly why it slipped through), yet
the fraud check that one branch added is nowhere in the committed result. A
plain git diff against either parent never shows it, because against main
the merge looks like a clean refund change, and against the feature branch it
looks like a clean refund change too. Only re-merging the two parents and
comparing surfaces the hole.
If casualty finds a hole in your history, star the repo so the next person hears about it before they ship the bug.
Here is casualty running cold on Flask,
across its last 50 merges. Verbatim output (trimmed for length):
$ casualty a5f9742..HEAD
casualty · a5f9742..HEAD
115 commit(s) scanned · 49 merge(s) re-merged
3301232 Merge branch 'stable'
parents ed1c9e9 + 85793d6 · conflicted: CHANGES.rst, docs/templating.rst, pyproject.toml, uv.lock
AMBIGUOUS docs/templating.rst:143 from second parent (the branch you merged in)
| If you want to register your own filters in Jinja you have two ways to do
AMBIGUOUS pyproject.toml:3 from second parent (the branch you merged in)
| version = "3.1.2"
daca74d Merge branch 'stable'
parents d98eb69 + f00ad42 · conflicted: CHANGES.rst, pyproject.toml, src/flask/ctx.py, ...
AMBIGUOUS src/flask/ctx.py:367-376 from second parent (the branch you merged in)
| @property
| def session(self) -> SessionMixin:
| """The session data associated with this request. Not available until
| ... and 6 more line(s)
Summary: 21 casualties across 6 merge(s) (0 clear drop(s), 21 ambiguous).Every finding here is AMBIGUOUS, and that is the correct, honest verdict:
all six merges had real conflicts (version bumps in pyproject.toml, the
stable backport churn), and content discarded during a genuine conflict
resolution may well have been discarded on purpose. casualty refuses to call
any of them a definite bug. Zero clear drops in 49 merges is the healthy
result you want from a mature project. The whole 50-merge scan finishes in about
27 seconds on a laptop.
npx casualty main~50..main # nothing to installOr globally:
npm install -g casualtyZero dependencies, Node 18 or newer, and git on your PATH. That is the entire
supply chain. It is structurally read-only: it shells out to git plumbing
(rev-list, merge-tree, diff, cat-file) and never writes to your index,
working tree, or object store.
Run it inside any git repo, with any rev-list range:
casualty main~50..main # the last 50 merges into main
casualty v1.0..v2.0 # everything between two releases
casualty HEAD~200..HEAD --json # machine-readable, for CI
casualty release..main -C ~/src/x # any range, any repoOptions:
| Flag | Effect |
|---|---|
--json |
structured output for scripts and CI |
-C <dir> |
run against a repo in another directory |
--tip <ref> |
ref used for the "reintroduced later?" check (default: the range end) |
--fail-on-clear |
exit 1 if any CLEAR DROP is found, for gating a pipeline |
--include-generated |
also scan lock files and vendored/generated trees (off by default) |
--no-color |
disable ANSI colors (also respects NO_COLOR) |
Exit code 0 on success, 1 with --fail-on-clear when a clear drop is found,
2 on usage or repository errors.
For every two-parent merge M in the range, with parents P1 and P2 and
merge base B:
- Re-merge.
git merge-tree --write-tree P1 P2re-runs the merge the way git itself would today: it finds the base, detects renames, does three-way content merges. This is the same remerge primitivegit log --remerge-diffis built on.casualtyuses it to learn which files conflicted, which sets the confidence of every finding in that file. - Recover each parent's contribution.
git diff -w -U0 B Pgives the exact lines each parent added relative to the base. Those, and only those, are the candidate lines. Diffing against the base (not against the conflict-marked re-merge tree) is the key correctness choice: superseded base content (a bumpedVERSIONconstant) is excluded by construction, whitespace is ignored, and line numbers stay honest. - Find what is missing. A candidate line is a casualty if it did not survive into the committed merge tree and was not reintroduced by a later commit up to the range tip.
- Grade it. A clean (non-conflicted) merge that nonetheless dropped a parent's contribution is a CLEAR DROP: there was no conflict, so there was no decision to discard anything, so the absence is almost certainly an accident. A dropped line in a file that did conflict is AMBIGUOUS: conflict resolution legitimately discards one side sometimes, so this is a lead to review, not a verdict.
A merge auditor that fires on every reformatted line is noise nobody reads. The false-positive guards, each with a test that proves it:
- Whitespace and reindentation are ignored (
-w). Reformatting is never a casualty. - Modified-not-lost lines survive. A line the merge kept but trimmed a
trailing comment from, or reindented, matches by normalized form. Without this,
return name # type: ignorevsreturn namewould be a false positive (and was, on Flask, until this guard). - Superseded base content is excluded. Because candidates come from the base-to-parent diff, a value the other side legitimately changed is never a candidate in the first place.
- Later reintroduction is checked. Content dropped at the merge but added
back by a subsequent commit is not reported. Point
--tipat the merge itself to see it as it was on the day. - Trivial lines do not drive findings. A lone
}or blank line is never a casualty on its own. - Conflicted files are downgraded to AMBIGUOUS, never reported as clear drops, because discarding one side of a conflict is a normal, intentional act.
- Lock files and vendored/generated trees are skipped by default, where a
"lost line" is regeneration churn, not human code.
--include-generatedopts back in.
- Two-parent merges only. Octopus (3+ parent) merges are reported in the
summary but not analyzed:
merge-treere-merges exactly two commits, andcasualtywill not guess at an N-way merge it cannot faithfully reconstruct. - Line-level, not semantic. It finds lost text, not lost meaning. A hunk that was reworded as it was kept is correctly treated as survived.
- Partial clones degrade gracefully. A merge whose objects git cannot read
(a missing blob in a
--filterclone) is counted as skipped, not fatal; the rest of the range is still audited.
| Tool | What it does | What it misses |
|---|---|---|
| casualty | re-merges each merge's parents and reports parent content absent from the recorded merge | semantic loss; octopus merges (by design) |
git log --remerge-diff |
shows what conflict resolution changed, per merge, for a human to read | no audit, no verdict, no range rollup, no clear-vs-ambiguous grading; you read every merge by hand |
git diff M^1 M / M^2 M |
the merge against one parent | the other parent's lost content is invisible against either single parent (that is the whole trap) |
| code review | catches it if a reviewer reads the full merge diff carefully | merges are the diffs people rubber-stamp; that is why this class of bug exists |
The niche is specific: turning git's remerge primitive into a forensic audit that names lost code, grades its confidence, and scales to a range. As of mid-2026, no maintained tool does this.
The detection core is small and the false-positive bar is the whole game. See CONTRIBUTING.md. New detection behavior ships with a fixture that proves both a true positive and the guard that suppresses its matching false positive.
The GIF at the top is the most useful thing in this README. Here is the exact cast that produces it: a two-file clean merge that drops a fraud check, then casualty catching it. Run it from an empty scratch directory. It builds a throwaway repo, runs the tool, and leaves nothing behind but a terminal you can record.
set -e
tmp="$(mktemp -d)"; cd "$tmp"
git init -q -b main && git config user.email d@e.f && git config user.name demo
# base commit: a payments file with both functions
cat > payments.py <<'PY'
def charge(customer, amount):
gateway.charge(customer, amount)
def refund(customer, amount):
gateway.refund(customer, amount)
PY
git add payments.py && git commit -qm "base: charge + refund"
BASE=$(git rev-parse --short HEAD)
# feature branch adds a fraud check inside charge()
git checkout -q -b fraud-check
cat > payments.py <<'PY'
def charge(customer, amount):
if fraud.is_suspicious(customer, amount):
raise FraudError("charge blocked by fraud check")
gateway.charge(customer, amount)
def refund(customer, amount):
gateway.refund(customer, amount)
PY
git commit -qam "add fraud check to charge()"
# main changes refund() in a non-overlapping region
git checkout -q main
cat > payments.py <<'PY'
def charge(customer, amount):
gateway.charge(customer, amount)
def refund(customer, amount):
log.info("refund issued")
gateway.refund(customer, amount)
PY
git commit -qam "log refunds"
# merge without committing, then drop the fraud check from the merge tree
# itself: git auto-merged it cleanly, a human resolution silently removed it
git merge --no-commit --no-ff fraud-check || true
cat > payments.py <<'PY'
def charge(customer, amount):
gateway.charge(customer, amount)
def refund(customer, amount):
log.info("refund issued")
gateway.refund(customer, amount)
PY
git add payments.py && git commit -qm "Merge fraud-check into main"
# the reveal: casualty catches the fraud check that never made it in
npx -y casualty "$BASE..HEAD"Record it with asciinema (asciinema rec demo.cast)
or charmbracelet/vhs for a GIF, keep it
under 30 seconds, no audio, then save it to assets/demo.gif and swap in the
image tag left as a comment near the top of this file.
For discovery, set these under the About sidebar (Settings gear next to About):
git git-forensics merge merge-conflict cli developer-tools code-review merge-queue lost-code zero-dependencies
MIT, Ben Malaga.