diff --git a/tools/wj-status.sh b/tools/wj-status.sh new file mode 100755 index 00000000..d133cd53 --- /dev/null +++ b/tools/wj-status.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# +# What is on wj that is not on main, and where each commit belongs. +# +# tools/wj-status.sh classify everything unpromoted +# tools/wj-status.sh --product print just the promotable SHAs +# +# The second form is meant to be read by a person and then pasted, oldest +# first: +# +# tools/promote-to-main.sh $(tools/wj-status.sh --product) +# +# HOW A COMMIT IS CLASSIFIED +# +# By a trailer you write at commit time: +# +# Scope: product belongs on main, the rest of the world should have it +# Scope: site belongs to this site alone, keep it on wj +# +# Intent is the only thing that can actually decide this, and you have it when +# you write the commit, not months later when you are staring at a list of +# subject lines trying to remember. +# +# Where there is no trailer this falls back to paths, but the fallback is +# deliberately weak, and says so. Under ADR-015 the only site-specific home in +# the tree is scripts/site_imports// - everything else is meant to be +# product with settings and seed data carrying the differences. So "touches +# only site_imports" is the one confident site verdict available, and anything +# else is reported as UNMARKED rather than guessed into a bucket. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +MODE="${1:-report}" + +if ! git show-ref --verify --quiet refs/heads/main; then + echo "ERROR: no main branch here." >&2 + exit 1 +fi + +RANGE="main..wj" +if ! git rev-parse --verify --quiet refs/heads/wj >/dev/null; then + echo "ERROR: no wj branch here." >&2 + exit 1 +fi + +COMMITS="$(git rev-list --reverse "$RANGE")" + +if [ -z "$COMMITS" ]; then + echo "Nothing on wj that main does not have." + exit 0 +fi + +classify() { + local sha="$1" + local scope + scope="$(git log -1 --format='%(trailers:key=Scope,valueonly)' "$sha" | tr -d '[:space:]')" + case "$(printf '%s' "$scope" | tr '[:upper:]' '[:lower:]')" in + product) echo product; return ;; + site) echo site; return ;; + esac + + # No trailer. The only confident verdict from paths alone. + local files outside + files="$(git show --pretty=format: --name-only "$sha" | sed '/^$/d')" + outside="$(printf '%s\n' "$files" | grep -v '^scripts/site_imports/' || true)" + if [ -n "$files" ] && [ -z "$outside" ]; then + echo site-by-path + else + echo unmarked + fi +} + +if [ "$MODE" = "--product" ]; then + for sha in $COMMITS; do + if [ "$(classify "$sha")" = "product" ]; then + printf '%s ' "$sha" + fi + done + echo + exit 0 +fi + +product_shas="" +unmarked_count=0 + +echo "On wj, not on main - oldest first" +echo + +for sha in $COMMITS; do + verdict="$(classify "$sha")" + subject="$(git log -1 --format='%h %s' "$sha")" + case "$verdict" in + product) + echo " [PRODUCT ] $subject" + product_shas="$product_shas $sha" + ;; + site) + echo " [SITE ] $subject" + ;; + site-by-path) + echo " [site? ] $subject" + echo " touches only scripts/site_imports/ - looks site-only, not marked" + ;; + *) + echo " [UNMARKED] $subject" + unmarked_count=$((unmarked_count + 1)) + ;; + esac +done + +echo +if [ -n "$product_shas" ]; then + echo "Marked for main:" + echo " tools/promote-to-main.sh$product_shas" + echo +fi + +if [ "$unmarked_count" -gt 0 ]; then + echo "$unmarked_count commit(s) carry no Scope trailer." + echo + echo "That is not a failure - it just means nobody decided yet. Under ADR-015" + echo "most work is product: site differences are meant to live in settings and" + echo "seed data, not in a branch. So an unmarked commit is more likely to" + echo "belong on main than not, and leaving it unmarked is how it ends up" + echo "stranded here." + echo + echo "Mark future ones as you go:" + echo " git commit -m \"...\" -m \"Scope: product\"" + echo + echo "Or decide now and promote the ones that belong:" + echo " tools/promote-to-main.sh --dry-run " +fi