Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/branch-supersede-check
1 change: 1 addition & 0 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ it documents actually live.
<!-- BEGIN GENERATED SKILLS TABLE -->
| Skill | Description |
| ----- | ----------- |
| [branch-supersede-check](skills/branch-supersede-check/SKILL.md) | Decide whether an unmerged branch still holds work that trunk does not have, before deleting or reviving it. Compares every touched file against main as it is now, instead of trusting the three-dot diff — which looks identical for a branch carrying real work and one whose work already landed by another route. Use when a branch looks unmerged but might be superseded, when triaging stale branches, or when `git diff main...branch` shows changes and you need to know whether they still matter. |
| [docs-maintainer](skills/docs-maintainer/SKILL.md) | Use when keeping repository documentation consistent after code, CLI, release, workflow, README, wiki, or GitHub Pages changes. Helps update docs surfaces without inventing behavior. |
| [mqlaunch-command-surface](skills/mqlaunch-command-surface/SKILL.md) | Use when changing macos-scripts mqlaunch commands, terminal GUI menus, HAL routing, command aliases, help text, or CLI/TUI command-surface behavior. |
| [mqlaunch-menu-template](skills/mqlaunch-menu-template/SKILL.md) | > |
Expand Down
167 changes: 167 additions & 0 deletions skills/branch-supersede-check/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
name: branch-supersede-check
description: Decide whether an unmerged branch still holds work that trunk does not have, before deleting or reviving it. Compares every touched file against main as it is now, instead of trusting the three-dot diff — which looks identical for a branch carrying real work and one whose work already landed by another route. Use when a branch looks unmerged but might be superseded, when triaging stale branches, or when `git diff main...branch` shows changes and you need to know whether they still matter.
---

# Branch Supersede Check

`git diff main...branch` is the wrong instrument for the question "does this
branch still matter", and it fails silently.

The three-dot form compares against the **merge base** — the point where the
branch forked. It lists everything the branch did since then, whether or not
trunk has since acquired the same content by a different route: a squash merge,
a cherry-pick, a rewrite, someone doing the same work in another PR. A branch
whose work landed months ago produces exactly the same shape of output as one
holding something unique. Same file count, same additions, same confident green
`+325 insertions`.

What separates them is a per-file comparison against trunk **as it is now**.

This skill does that comparison. It does not decide for you.

---

## Evals

### Should trigger

* "is this branch safe to delete?"
* "does this branch still have anything we need?"
* "the diff shows changes but I think this already landed"
* "triage these stale branches"
* "why does git refuse to delete this branch with -d?"

### Should not trigger

* "which branches exist and which are merged?" → run the `mq-checkbranch`
skill (personal, not in this repo)
* "merge this pull request" → `terminal/launchers/gitpr-merge-safe.sh`
* "what changed in this PR?" → a plain `git diff` is the right tool there

---

## Run it

The script lives with this skill. Both agent tools reach the same copy:
`~/.agents/skills/branch-supersede-check` and `~/.claude/skills/branch-supersede-check`
are symlinks to it.

```bash
S=~/macos-scripts/skills/branch-supersede-check/scripts/supersede-report.sh

"$S" <branch> # judge a branch in the current repo
"$S" <branch> --repo ~/some-repo # judge one elsewhere
"$S" <branch> --base develop # trunk is not called main
"$S" <branch> --verbose # print the diffs too
"$S" <branch> --no-pr # skip the merged-PR lookup, no network
```

Read-only. It never checks out, merges, resets, or deletes. The one network
call is the merged-PR lookup, skipped with `--no-pr` or when `gh` is absent.

Accepts a branch name, tag, or SHA. Exit status: `0` superseded, `1` needs
review, `2` bad usage or unknown ref.

---

## Reading the output

Every file the branch touched gets one of five labels:

* `IDENTICAL` — byte-for-byte the same as trunk. The work landed.
* `BASE-AHEAD` — trunk has lines this branch does not, and the branch adds
nothing. The branch is simply behind.
* `BRANCH-AHEAD` — the branch has lines trunk does not, and removes nothing.
* `DIVERGED` — both sides have lines the other lacks.
* `ONLY-ON-BRANCH` — the file does not exist in trunk at all.

The summary line is the signal. A high `identical` count is the strongest
evidence a branch is superseded, because identical files do not happen by
accident — they mean this exact content is already in trunk.

---

## What it cannot tell you

`BRANCH-AHEAD`, `DIVERGED` and `ONLY-ON-BRANCH` mean the text differs. They do
**not** mean the difference is worth keeping. This is where the tool stops and
you read.

Three real cases where "has unique content" was true and the branch was still
right to delete:

* A branch adding a name to an inventory list. Trunk had already added the same
name independently, in a different position. Textually unique; applying it
would have duplicated the entry.
* A branch adding a skill directory that trunk does not contain — because the
skill had been deliberately moved to another repo.
* A branch with a CI workflow and docs that `DIVERGED`. Trunk's version was a
superset: it had two extra check steps the branch would have removed, and a
deliberate comment explaining why a `paths:` filter had been taken out. The
branch was not different. It was older, and reviving it would have regressed
CI.

So: **a `DIVERGED` file usually means the branch is behind, not ahead.** Check
the direction before assuming there is something to rescue.

---

## The corroborating checks

The file comparison is one input. Two others settle most cases:

**A merged PR whose head was this branch.** Squash merging leaves no ancestry
and no identical files, so a squash-merged branch can look entirely unique. The
script looks this up automatically when the branch ref still exists locally.
If you are checking a SHA whose branch is already deleted, the lookup is
skipped — pass the name instead, or check by hand:

```bash
gh pr list --state merged --limit 200 --json number,headRefName,mergedAt \
--jq '.[] | select(.headRefName == "<branch>")'
```

**Commits after the merge.** A PR can be merged and the branch keep moving.
Compare the branch tip date against the PR's `mergedAt`; anything later is not
in trunk. `mq-checkbranch` flags this as
`[#N merged, but commits came after — verify]`.

---

## Deleting

Never on the tool's word alone, and never unattended.

A superseded branch needs `git branch -D`, not `-d`. Git refuses `-d` because
the commits are formally not in trunk even when every byte of their content is.
That refusal is not a warning worth heeding here — it is measuring ancestry,
which is exactly the thing squash merging destroys.

Record the SHA before deleting. It stays in the reflog for a while, and a
one-line note costs nothing:

```bash
printf '%s\t%s\n' "$branch" "$(git rev-parse "$branch")" >> deleted-branches.tsv
```

---

## Related

* `mq-checkbranch` — inventories every branch in a repo and buckets them as
merged / stale / active. Use it first to find candidates; use this one to
judge a specific candidate it could not classify.

---

## Why this exists

Four branches in one day looked like unmerged work and were not. The pattern is
not rare and it is not obvious, because the misleading signal is a green one:
a diff that renders, with plausible content, in a format everyone trusts.

The general form is worth carrying beyond git. A check that passes is a claim
about what it measured, not about what you wanted to know. When something looks
settled, ask what the output would look like if it were wrong — and if the
answer is "exactly like this", find a different measurement.
182 changes: 182 additions & 0 deletions skills/branch-supersede-check/scripts/supersede-report.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Decide whether a branch still carries work that trunk does not have.
#
# The question looks like it should be a diff, and it is not. `git diff
# base...branch` compares against the MERGE BASE, so it lists everything the
# branch did since it forked — whether or not trunk has since acquired the
# same content by another route. A branch whose work already landed produces
# exactly the same shape of output as one holding something unique.
#
# What separates them is a per-file comparison against trunk as it is NOW
# (two-dot). This walks every file the branch touched and classifies it.
#
# Read-only. Never checks out, merges, or deletes.
set -uo pipefail

BASE="main"
REPO="."
BRANCH=""
VERBOSE=0
NO_PR=0

usage() {
cat <<'USAGE'
usage: supersede-report.sh <branch> [--repo PATH] [--base REF] [--verbose]

<branch> branch, tag or SHA to judge
--repo PATH repository to run in (default: current directory)
--base REF trunk to compare against (default: main)
--verbose also print, per differing file, which side has more
--no-pr skip the merged-PR lookup (no network, no gh)

Exit status: 0 superseded, 1 has unique content, 2 usage or lookup error.
USAGE
}

while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="${2:-}"; shift 2 ;;
--base) BASE="${2:-}"; shift 2 ;;
--verbose) VERBOSE=1; shift ;;
--no-pr) NO_PR=1; shift ;;
-h|--help) usage; exit 0 ;;
-*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
*) BRANCH="$1"; shift ;;
esac
done

[[ -n "$BRANCH" ]] || { usage >&2; exit 2; }

git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| { echo "not a git repository: $REPO" >&2; exit 2; }

git() { command git -C "$REPO" "$@"; }

for ref in "$BASE" "$BRANCH"; do
git rev-parse --verify --quiet "$ref^{commit}" >/dev/null \
|| { echo "no such ref: $ref" >&2; exit 2; }
done

branch_sha="$(git rev-parse --short "$BRANCH")"
base_sha="$(git rev-parse --short "$BASE")"
merge_base="$(git merge-base "$BASE" "$BRANCH" 2>/dev/null || echo '')"

echo "=== $BRANCH ($branch_sha) vs $BASE ($base_sha) ==="

# Ancestry and PR evidence are cheap and answer the question outright when they
# apply. A branch already in trunk needs no file comparison.
if git merge-base --is-ancestor "$BRANCH" "$BASE" 2>/dev/null; then
echo " ANCESTOR OF $BASE — every commit is already in trunk."
echo
echo "VERDICT: superseded (by ancestry)"
exit 0
fi

ahead="$(git rev-list --count "$BASE..$BRANCH" 2>/dev/null || echo '?')"
behind="$(git rev-list --count "$BRANCH..$BASE" 2>/dev/null || echo '?')"
echo " commits: $ahead not in $BASE, $behind in $BASE not here"
[[ -n "$merge_base" ]] && echo " forked at: $(git rev-parse --short "$merge_base") ($(git log -1 --format=%ar "$merge_base"))"
echo " last commit: $(git log -1 --format='%ar (%ai)' "$BRANCH")"
echo

# The misleading view, printed on purpose so the two can be compared.
three_dot="$(git diff --name-only "$BASE...$BRANCH" 2>/dev/null | wc -l | tr -d ' ')"
echo " three-dot diff ($BASE...$BRANCH) touches $three_dot file(s)"
echo " — that is measured against the fork point, so it looks the same"
echo " whether or not trunk already has the content. Per-file, vs $BASE now:"
echo

identical=0; base_ahead=0; branch_ahead=0; diverged=0; only_branch=0

while IFS= read -r f; do
[[ -n "$f" ]] || continue

if ! git cat-file -e "$BASE:$f" 2>/dev/null; then
printf ' ONLY-ON-BRANCH %s\n' "$f"
only_branch=$((only_branch + 1))
continue
fi

if git diff --quiet "$BASE" "$BRANCH" -- "$f" 2>/dev/null; then
printf ' IDENTICAL %s\n' "$f"
identical=$((identical + 1))
continue
fi

# numstat runs base -> branch: added = lines only the branch has,
# removed = lines only the base has.
read -r added removed _ < <(git diff --numstat "$BASE" "$BRANCH" -- "$f" 2>/dev/null)
added="${added:-0}"; removed="${removed:-0}"
[[ "$added" == "-" ]] && added=0 # binary files report as -
[[ "$removed" == "-" ]] && removed=0

if [[ "$added" -eq 0 && "$removed" -gt 0 ]]; then
printf ' BASE-AHEAD %s (%s has %s line(s) the branch lacks)\n' "$f" "$BASE" "$removed"
base_ahead=$((base_ahead + 1))
elif [[ "$added" -gt 0 && "$removed" -eq 0 ]]; then
printf ' BRANCH-AHEAD %s (+%s)\n' "$f" "$added"
branch_ahead=$((branch_ahead + 1))
else
printf ' DIVERGED %s (+%s/-%s)\n' "$f" "$added" "$removed"
diverged=$((diverged + 1))
fi

if [[ "$VERBOSE" -eq 1 ]]; then
git diff "$BASE" "$BRANCH" -- "$f" | sed 's/^/ /' | head -20
fi
done < <(git diff --name-only "$BASE...$BRANCH" 2>/dev/null)

total=$((identical + base_ahead + branch_ahead + diverged + only_branch))
echo
printf ' identical %s · base-ahead %s · branch-ahead %s · diverged %s · only-on-branch %s (of %s)\n' \
"$identical" "$base_ahead" "$branch_ahead" "$diverged" "$only_branch" "$total"
echo

# A merged PR whose head was this branch is the evidence squash-merging
# destroys: no ancestry, no identical files, and the work is still in trunk.
# Checked last because it is the only step that touches the network.
pr_note=""
if [[ "$NO_PR" -eq 0 ]] && command -v gh >/dev/null 2>&1; then
name="$BRANCH"
if ! git show-ref --verify --quiet "refs/heads/$name"; then
name="$(git for-each-ref --format='%(refname:short)' --points-at "$BRANCH" refs/heads 2>/dev/null | head -1)"
fi
if [[ -n "$name" ]]; then
pr_note="$(gh pr list --state merged --limit 200 --json number,headRefName,mergedAt \
--jq ".[] | select(.headRefName == \"$name\") | \"#\(.number) merged \(.mergedAt[0:10])\"" 2>/dev/null | head -1)"
fi
fi
[[ -n "$pr_note" ]] && echo " merged PR for this head: $pr_note" && echo

unique=$((branch_ahead + diverged + only_branch))
settled=$((identical + base_ahead))

if [[ "$total" -eq 0 || "$unique" -eq 0 ]]; then
echo "VERDICT: superseded — nothing here is missing from $BASE."
echo " Deleting needs -D: the commits are formally not in $BASE even"
echo " though the content is."
exit 0
fi

# Everything below has files trunk does not literally contain. That is where
# the tool stops being able to decide and starts owing you a reason to look.
if [[ -n "$pr_note" ]]; then
echo "VERDICT: review — $unique file(s) differ, but $pr_note."
echo " A squash merge leaves no ancestry and no identical files, so a"
echo " differing file here is expected. Check whether the difference is"
echo " work added AFTER the merge, or just the pre-squash shape."
elif [[ "$settled" -gt "$unique" ]]; then
echo "VERDICT: review, leaning superseded — $settled of $total file(s) are"
echo " identical to $BASE or behind it, and $unique are not."
echo " That ratio usually means the work landed by another route and"
echo " trunk moved on. Read the $unique before deciding."
else
echo "VERDICT: review — $unique of $total file(s) hold something $BASE does not."
fi

echo
echo " This tool compares text, not worth. A DIVERGED file often means the"
echo " branch is simply older, and an ONLY-ON-BRANCH file can be something"
echo " deliberately moved or dropped. Read them:"
echo " git -C $REPO diff $BASE $BRANCH -- <file>"
exit 1
Loading