From bf2d24f4b58ece95d916a6ff3d732f28ab5d4c38 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 15:04:13 +0200 Subject: [PATCH] fix(git): a merge driver declared after a branch was cut never fires on it Seven open PRs could not be updated onto main today. Two of them conflicted only on docs/audits/impl-estimates.jsonl -- a file that is strictly append-only and for which #3256 (768e740b1) already declared `docs/audits/*.jsonl merge=union`. Git reads merge attributes from the side being merged INTO. A branch cut before the rule landed carries a .gitattributes without it, so the driver is never consulted: the declaration is retroactively inert. agent/T-2 is the measured case -- its .gitattributes lists only the roadmap rule, 768e740b1 is not an ancestor, and the merge conflicts. The blobs were never the problem: `git merge-file --union` on the three stages returns rc=0, 50 rows, 0 markers. This script already solves that shape for roadmap.yaml by registering the driver per invocation rather than in the shared config. Bind union the same way, with core.attributesFile -- the LOWEST priority source, so it applies only where the branch has no rule of its own and a branch that already carries the declaration is untouched. Nothing shared is written; the temp file dies with the process. Verified on #3271, whose merge had failed minutes earlier: rc=0, all three of Makefile, impl-estimates.jsonl and roadmap.yaml auto-merged, 49 rows with 0 invalid and 0 duplicate, both roadmap guards PASS. Row 9 carries both polarities -- the fixture must CONFLICT without the override and merge WITH it -- because an override that is never needed cannot be told from one that does not work. Mutation-verified: blanking the override file turns row 9 red (8/9), restoring it green (9/9). Closes #3308 Pmat-Ticket: PMAT-3308 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/ci_resolve_dirty.sh | 71 +++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/ci_resolve_dirty.sh b/scripts/ci_resolve_dirty.sh index 2e65b05778..51ac0c9289 100755 --- a/scripts/ci_resolve_dirty.sh +++ b/scripts/ci_resolve_dirty.sh @@ -31,6 +31,14 @@ PROG="${0##*/}" REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" DRIVER_SCRIPT="$REPO_ROOT/scripts/lib/roadmap_merge.py" DRIVER_CONFIG_KEY="merge.roadmap.driver" +# A .gitattributes merge-driver declaration is RETROACTIVELY INERT. Git reads +# attributes from the side being merged INTO, so a branch cut before the rule +# landed never sees it: #3256 declared `docs/audits/*.jsonl merge=union` on +# 768e740b1, and every older PR still conflicted on that append-only ledger by +# hand -- measured on #3005 and #3271. core.attributesFile is the lowest-priority +# source, so this binds ONLY where the branch has no rule of its own, and it is +# passed per invocation: nothing in the shared .git is written. +ATTRS_OVERRIDE="" APPLY=0 NO_PUSH=0 @@ -106,6 +114,19 @@ select_dirty() { ST_N=0 ST_RED=0 +write_attrs_override() { + ATTRS_OVERRIDE=$(mktemp "${TMPDIR:-/tmp}/ci_resolve_attrs.XXXXXX") + printf 'docs/audits/*.jsonl merge=union\n' > "$ATTRS_OVERRIDE" +} + +git_merge_ctx() { # git_merge_ctx WORKTREE ARGS... -- git with both drivers bound + local wt="$1"; shift + git -C "$wt" \ + -c "$DRIVER_CONFIG_KEY=python3 $DRIVER_SCRIPT %O %A %B" \ + -c "core.attributesFile=$ATTRS_OVERRIDE" \ + "$@" +} + st_row() { # st_row RC LABEL [detail...] local line ST_N=$((ST_N + 1)) @@ -372,11 +393,54 @@ SHIM rm -f -- "$GH_SHIM_MARKER" fi + # row 9: the retroactive-inertness fixture. A branch whose .gitattributes + # predates `docs/audits/*.jsonl merge=union` must conflict WITHOUT the + # override and merge cleanly WITH it. Both polarities, or the row proves + # nothing: an override that is never needed is indistinguishable from one + # that does not work. + local ur rc_no rc_yes detail="" + ur=$(mktemp -d "${TMPDIR:-/tmp}/ci_resolve_union.XXXXXX") + ( + cd "$ur" || exit 1 + git init -q -b main . && git config user.email t@t && git config user.name t + mkdir -p docs/audits + # the branch point carries NO rule for *.jsonl -- that is the defect + printf 'docs/roadmaps/roadmap.yaml merge=roadmap\n' > .gitattributes + printf '{"id":0}\n' > docs/audits/e.jsonl + git add -A && git commit -q -m base + git checkout -q -b older + printf '{"id":1}\n' >> docs/audits/e.jsonl && git commit -q -am older + git checkout -q main + printf '{"id":2}\n' >> docs/audits/e.jsonl && git commit -q -am mainside + git checkout -q older + ) >/dev/null 2>&1 || true + rc_no=0 + git -C "$ur" merge --no-commit --no-ff main >/dev/null 2>&1 || rc_no=$? + git -C "$ur" merge --abort >/dev/null 2>&1 || true + rc_yes=0 + git -C "$ur" -c "core.attributesFile=$ATTRS_OVERRIDE" \ + merge --no-commit --no-ff main >/dev/null 2>&1 || rc_yes=$? + if [ "$rc_no" -eq 0 ]; then + detail='no-override merge did NOT conflict (the fixture proves nothing)' + elif [ "$rc_yes" -ne 0 ]; then + detail='override did not resolve the append-only ledger' + elif [ "$(grep -c '"id"' "$ur/docs/audits/e.jsonl")" -ne 3 ]; then + detail="union lost a row: $(grep -c '"id"' "$ur/docs/audits/e.jsonl") of 3" + fi + rm -rf "${ur:?}" + if [ -z "$detail" ]; then + st_row 0 'union binds on a branch older than the declaration (both polarities)' + else + st_row 1 'union binds on a branch older than the declaration (both polarities)' "$detail" + fi + printf '%s/%s rows, %s failed\n' "$((ST_N - ST_RED))" "$ST_N" "$ST_RED" [ "$ST_RED" -eq 0 ] } if [ "$SELFTEST" -eq 1 ]; then + write_attrs_override + trap 'rm -f -- "${ATTRS_OVERRIDE:-/nonexistent}"' EXIT selftest exit $? fi @@ -396,7 +460,7 @@ resolve_one() { # resolve_one NUMBER HEADREF head_sha=$(git -C "$td_wt" rev-parse HEAD) if [ "$APPLY" -eq 1 ]; then - if git -C "$td_wt" -c "$DRIVER_CONFIG_KEY=python3 $DRIVER_SCRIPT %O %A %B" \ + if git_merge_ctx "$td_wt" \ merge --no-edit -m "merge origin/main (roadmap 3-way by id)" \ origin/main >/dev/null 2>&1; then merge_sha=$(git -C "$td_wt" rev-parse HEAD) @@ -419,7 +483,7 @@ resolve_one() { # resolve_one NUMBER HEADREF git -C "$td_wt" merge --abort >/dev/null 2>&1 || true fi else - if ! git -C "$td_wt" -c "$DRIVER_CONFIG_KEY=python3 $DRIVER_SCRIPT %O %A %B" \ + if ! git_merge_ctx "$td_wt" \ merge --no-commit --no-ff origin/main >/dev/null 2>&1; then dirty_files=$(git -C "$td_wt" diff --name-only --diff-filter=U | wc -l | tr -d ' ') git -C "$td_wt" merge --abort >/dev/null 2>&1 || true @@ -430,6 +494,9 @@ resolve_one() { # resolve_one NUMBER HEADREF git worktree remove -f "${td_wt:?}" } +write_attrs_override +trap 'rm -f -- "${ATTRS_OVERRIDE:-/nonexistent}"' EXIT + while IFS=$'\t' read -r pr_number head_ref; do [ -n "$pr_number" ] || continue resolve_one "$pr_number" "$head_ref"