diff --git a/.claude/skills/branch-supersede-check b/.claude/skills/branch-supersede-check new file mode 120000 index 00000000..a5c4a224 --- /dev/null +++ b/.claude/skills/branch-supersede-check @@ -0,0 +1 @@ +../../skills/branch-supersede-check \ No newline at end of file diff --git a/SKILLS.md b/SKILLS.md index 5d2bdcdd..150e0974 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -15,6 +15,7 @@ it documents actually live. | 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) | > | diff --git a/skills/branch-supersede-check/SKILL.md b/skills/branch-supersede-check/SKILL.md new file mode 100644 index 00000000..40fff6e2 --- /dev/null +++ b/skills/branch-supersede-check/SKILL.md @@ -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" # judge a branch in the current repo +"$S" --repo ~/some-repo # judge one elsewhere +"$S" --base develop # trunk is not called main +"$S" --verbose # print the diffs too +"$S" --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 == "")' +``` + +**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. diff --git a/skills/branch-supersede-check/scripts/supersede-report.sh b/skills/branch-supersede-check/scripts/supersede-report.sh new file mode 100755 index 00000000..5579b3e2 --- /dev/null +++ b/skills/branch-supersede-check/scripts/supersede-report.sh @@ -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 [--repo PATH] [--base REF] [--verbose] + + 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 -- " +exit 1