The stuff this cycle made, archived 2026-10-19 and rendered from
git show rev65-attempt11-iterations51of60:stuff/….
fold-reject-vs-ignore-readd.mdrule2-ancestry-test.mdrule2-local-repair.mdRule 1 asks for this: run merge cases A-E in a throwaway repo under both folds and show whether any verdict differs.
add k whose ancestry holds revoke k is rejected) and rule 2 at merges.
Rule 2 as passed says "one parent's history adds a key that the other parent's history revoked". Read literally, it rejects case B: L's history holds the base's add k1, and R revoked k1. The script uses the reading the rule gives as its reason: reject a merge when some add k in its ancestry is not an ancestor of some revoke k (an add made on a branch that does not contain the revoke).
| case | L | R | merge | ignore: merge | reject: merge | differs |
|---|---|---|---|---|---|---|
| A | {k2} ok | {k1} ok | {} | REJECT no-empty-revoke | REJECT no-empty-revoke | no |
| B | {k1 k2 k3} ok | {k3} ok | {k2 k3} | ok | ok | no |
| C | {k1 k2} ok | {} REJECT | {k2} | REJECT parent | REJECT parent | no |
| D | {k1} ok | {k2} ok / REJECT rule 1 | {} | REJECT no-empty-revoke | REJECT parent add k1 | R only |
| E | {k2} ok | {k1} ok | {} | REJECT no-empty-revoke | REJECT rule 2 | no |
| F | {k2 k3} ok | {k1 k2 k3} ok | {k2 k3} | ok | REJECT rule 2 | yes |
add k1 itself, where the ignore fold accepted it and gave the same live set {k2}. D's merge is rejected either way, for a different reason.{k2 k3}; rule 2 rejects the merge. So rule 2 does change a verdict, and the one it changes is a merge whose live set is non-empty and the same under both folds.
#!/usr/bin/env bash
# Throwaway repo: each commit's subject is "add kN" or "revoke kN".
set -e
g(){ git -c commit.gpgsign=false -c core.hooksPath=/dev/null -c user.name=t -c user.email=t@t "$@"; }
c(){ g commit -q --allow-empty -m "$1"; }
# live set: keys added in any ancestor minus keys revoked in any ancestor
live(){ comm -23 <(git log --format=%s "$1" | awk '$1=="add"{print $2}' | sort -u) \
<(git log --format=%s "$1" | awk '$1=="revoke"{print $2}' | sort -u) | xargs; }
ev(){ git log --format=%H --grep="^$2 $3\$" "$1" 2>/dev/null; } # "$2 $3" commits in $1's ancestry
# own check of commit $1 under fold $2: ignore (cycle 46) or reject (rules 1 and 2)
own(){ local x=$1 s n k a r; s=$(git log -1 --format=%s "$x"); n=$(git log -1 --format=%P "$x" | wc -w)
if [ "$2" = reject ]; then
if [ "${s%% *}" = add ]; then k=${s#add }; [ -z "$(ev "$x^" revoke "$k")" ] || { echo "rule 1: $k revoked by an ancestor"; return; }; fi
if [ "$n" -gt 1 ]; then for k in $(git log --format=%s "$x" | awk '$1=="revoke"{print $2}' | sort -u); do
for a in $(ev "$x" add "$k"); do for r in $(ev "$x" revoke "$k"); do
git merge-base --is-ancestor "$a" "$r" || { echo "rule 2: an add of $k is not an ancestor of its revoke"; return; }; done; done; done; fi
fi
if { [ "${s%% *}" = revoke ] || [ "$n" -gt 1 ]; } && [ -z "$(live "$x")" ]; then echo "no-empty-revoke"; return; fi
echo ok; }
verdict(){ local p o; for p in $(git log -1 --format=%P "$1"); do
[ "$(verdict "$p" "$2")" = ok ] || { echo "REJECT (parent '$(git log -1 --format=%s "$p")')"; return; }; done
o=$(own "$1" "$2"); [ "$o" = ok ] && echo ok || echo "REJECT ($o)"; }
show(){ local i r; i=$(verdict "$1" ignore); r=$(verdict "$1" reject)
[ "${i%% *}" = "${r%% *}" ] && d=same || d=DIFFERS
printf ' %-3s %-10s live={%s}\n ignore: %s\n reject: %s [%s]\n' "$2" "$(git log -1 --format=%s "$1")" "$(live "$1")" "$i" "$r" "$d"; }
run(){ git checkout -q --orphan "$1"; for m in $2; do c "${m/_/ }"; done; b=$(git rev-parse HEAD)
git checkout -q -b "$1-L" "$b"; for m in $3; do c "${m/_/ }"; done; L=$(git rev-parse HEAD)
git checkout -q -b "$1-R" "$b"; for m in $4; do c "${m/_/ }"; done; R=$(git rev-parse HEAD)
git checkout -q "$1-L"; g merge -q --no-ff -m "merge $1" "$1-R"
echo "== $1: base [$2] | L [$3] | R [$4]"; show "$L" L; show "$R" R; show HEAD M; }
git init -q r && cd r
run A "add_k1 add_k2" "revoke_k1" "revoke_k2"
run B "add_k1 add_k3" "add_k2" "revoke_k1"
run C "add_k1" "add_k2" "revoke_k1"
run D "add_k1 add_k2" "revoke_k2" "revoke_k1 add_k1"
run E "add_k1 add_k2" "revoke_k1" "revoke_k2 add_k1"
run F "add_k1 add_k2 add_k3" "revoke_k1" "add_k1"
bash run.sh
== A: base [add_k1 add_k2] | L [revoke_k1] | R [revoke_k2]
L revoke k1 live={k2}
ignore: ok
reject: ok [same]
R revoke k2 live={k1}
ignore: ok
reject: ok [same]
M merge A live={}
ignore: REJECT (no-empty-revoke)
reject: REJECT (no-empty-revoke) [same]
== B: base [add_k1 add_k3] | L [add_k2] | R [revoke_k1]
L add k2 live={k1 k2 k3}
ignore: ok
reject: ok [same]
R revoke k1 live={k3}
ignore: ok
reject: ok [same]
M merge B live={k2 k3}
ignore: ok
reject: ok [same]
== C: base [add_k1] | L [add_k2] | R [revoke_k1]
L add k2 live={k1 k2}
ignore: ok
reject: ok [same]
R revoke k1 live={}
ignore: REJECT (no-empty-revoke)
reject: REJECT (no-empty-revoke) [same]
M merge C live={k2}
ignore: REJECT (parent 'revoke k1')
reject: REJECT (parent 'revoke k1') [same]
== D: base [add_k1 add_k2] | L [revoke_k2] | R [revoke_k1 add_k1]
L revoke k2 live={k1}
ignore: ok
reject: ok [same]
R add k1 live={k2}
ignore: ok
reject: REJECT (rule 1: k1 revoked by an ancestor) [DIFFERS]
M merge D live={}
ignore: REJECT (no-empty-revoke)
reject: REJECT (parent 'add k1') [same]
== E: base [add_k1 add_k2] | L [revoke_k1] | R [revoke_k2 add_k1]
L revoke k1 live={k2}
ignore: ok
reject: ok [same]
R add k1 live={k1}
ignore: ok
reject: ok [same]
M merge E live={}
ignore: REJECT (no-empty-revoke)
reject: REJECT (rule 2: an add of k1 is not an ancestor of its revoke) [same]
== F: base [add_k1 add_k2 add_k3] | L [revoke_k1] | R [add_k1]
L revoke k1 live={k2 k3}
ignore: ok
reject: ok [same]
R add k1 live={k1 k2 k3}
ignore: ok
reject: ok [same]
M merge F live={k2 k3}
ignore: ok
reject: REJECT (rule 2: an add of k1 is not an ancestor of its revoke) [DIFFERS]
Rule 2 as passed reads: "a merge is rejected when one parent's history adds a key that the other parent's history revoked". Read literally it also rejects case B: there the add of k1 is in both parents, it came before the revoke, and the revoke removed it as it should. fold-reject-vs-ignore-readd.md already noted that its script used a narrower reading. This file states that reading as a test that can be run.
At merge M, for each key k, take every add a of k and every revoke r of k that are ancestors of M:
git merge-base --is-ancestor | Order | Verdict |
|---|---|---|
a is an ancestor of r | add, then revoke | pass: the revoke saw the add |
r is an ancestor of a | revoke, then add | rule 1 already rejected a |
| neither | concurrent | rule 2 rejects M |
So rule 2 only adds the third row. With rule 1 in force, it comes down to this: **reject M if some add of a revoked key k is an ancestor of no revoke of k.** An add that one revoke saw and a second, concurrent revoke did not see is still revoked, so it passes. The check never asks which parent holds what, so swapping the parents cannot change its verdict.
B: add vs revoke -> add before revoke: pass
F: orig add vs revoke -> add before revoke: pass
F: re-add vs revoke -> concurrent: rule 2 rejects
literal rule 2 on B -> rejects (the over-broad reading)
Case F: base adds k1, one branch revokes it, the other re-adds it without the revoke in its history. Only the re-add is concurrent with the revoke, so only it trips rule 2. The first add of k1 is an ancestor of the revoke and passes.
merge-base --is-ancestor per (add, revoke) pair of the same key, and only for keys that were ever revoked.Question: when rule 2 rejects a merge because one branch re-added a key that the other branch revoked (case F), can the re-adding branch fix it with commits of its own? History is append-only, so the only fix available is a new event, and the only event that can cancel an add is a revoke.
Both wordings were checked in a throwaway repo (/tmp/z47m1.*, empty [key] add|revoke kN commits, merges evaluated on the union of both parents' history):
rule2-ancestry-test.md)| run | case | literal | ancestry | merged live set |
|---|---|---|---|---|
| base {k1,k2} | B: X adds k3, Y revokes base k1 | reject | pass | {k2,k3} |
| base {k1,k2} | F: X re-adds k1 without seeing the revoke | reject | reject | {k2} |
| base {k1,k2} | F, after X revokes k1 itself (X live {k2}) | reject | pass | {k2} |
| base {k1} | F: X re-adds k1, Y adds k2 then revokes k1 | reject | reject | {k2} |
| base {k1} | F, after X adds k3 then revokes k1 | reject | pass | {k2,k3} |
Consequence for rule 2: adopt the ancestry wording. The literal wording is stricter, and the extra rejections can never be cleared.