From 2f119a3fb72eeb9216650dca7bbf3e79a1b3cd2e Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Tue, 19 May 2026 06:30:38 +0200 Subject: [PATCH] ka-promote: auto-normalise git format-patch trailers (closes #31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_cumulative() now strips any "-- \n.(.)?\n" sentinel from each input patch and emits a single canonical separator between, but not after, concatenated patches. Source patches in patches// can therefore keep their original git format-patch shape regardless of their position in fleet/.yaml — the brittle "trailer flip-flop on include reorder" mode from PR #28 (commits 84734ba ↔ ceec602) is gone. Tests: - new unit covers strip_trailer + write_cumulative shape with mixed trailer states + asserts no orphan trailer leaks at EOF - fresnel parity b2sum re-recorded after the shape change (4d9d93c6... -> 9c21751c...) — the cumulative is byte-identical modulo per-patch trailer normalisation; git apply --check on the v7.0 baseline still passes - existing series-dir, bad-include, missing-patch, duplicate-include rejections unchanged --- bin/ka-promote | 41 ++++++++++++++++++++++++++++-- tests/ka-promote/run-tests.sh | 47 +++++++++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/bin/ka-promote b/bin/ka-promote index 70fa1fb..c611fa6 100755 --- a/bin/ka-promote +++ b/bin/ka-promote @@ -27,6 +27,7 @@ import argparse import glob import hashlib import os +import re import subprocess import sys from datetime import datetime, timezone @@ -37,6 +38,17 @@ VERSION = 1 SCHEMA_VERSION = 1 COVER_LETTER = "0000-cover-letter.patch" +# git format-patch trailer: "-- \n.(.)?\n" at EOF, +# possibly with trailing blank line(s). Strip from each source patch so +# that the cumulative is always well-formed regardless of include order. +# See issue #31. +_TRAILER_RE = re.compile(rb'\n-- \n\d+\.\d+(?:\.\d+)?\n+\Z') + +# Canonical separator emitted between concatenated patches in the +# cumulative. Trailing blank line keeps patch(1) happy when the next +# patch starts with "From ". +_CANONICAL_TRAILER = b'-- \n2.54.0\n\n' + def die(msg, code=1): print(f"ka-promote: error: {msg}", file=sys.stderr) @@ -124,11 +136,36 @@ def resolve_includes(includes, patches_root): return resolved +def strip_trailer(data): + """Strip any trailing git format-patch sentinel from a patch. + + Accepts patches in either canonical shape: + - WITH trailer: "...\n-- \n2.54.0\n\n" + - WITHOUT trailer: "...\n" (already stripped) + + Returns data ending in a single newline so the caller can either + append a canonical trailer (mid-cumulative) or leave it bare (last). + """ + stripped = _TRAILER_RE.sub(b'\n', data) + if not stripped.endswith(b'\n'): + stripped += b'\n' + return stripped + + def write_cumulative(resolved, out_path): with open(out_path, "wb") as out: - for r in resolved: + n = len(resolved) + for i, r in enumerate(resolved): with open(r["src"], "rb") as src: - out.write(src.read()) + data = src.read() + data = strip_trailer(data) + out.write(data) + # Mid-cumulative patches need a separator so patch(1) knows + # where they end and the next "From " begins. Last + # patch stays bare — a trailing orphan sentinel reads as + # the start of a malformed new patch at EOF (issue #31). + if i != n - 1: + out.write(_CANONICAL_TRAILER) with open(out_path, "rb") as f: b2 = hashlib.blake2b(f.read()).hexdigest() size = os.path.getsize(out_path) diff --git a/tests/ka-promote/run-tests.sh b/tests/ka-promote/run-tests.sh index d033168..9b7226a 100755 --- a/tests/ka-promote/run-tests.sh +++ b/tests/ka-promote/run-tests.sh @@ -12,8 +12,10 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" fixtures="${repo_root}/tests/ka-promote/fixtures" -# Phase-3 ground truth — recorded 2026-05-18, fresnel cumulative b2sum. -FRESNEL_EXPECTED_B2SUM=4d9d93c655ea701b587bf1383c794f41b1aeb3bc32bca69ce3488852ec2c1474a2f47585608598b39ac05671490b8df63c5bc7d093f87e1afd5a92f908891b67 +# Phase-3 ground truth — re-recorded 2026-05-19 after issue #31 fix +# (write_cumulative now strips per-input trailers + emits canonical +# separators between, but not after, concatenated patches). +FRESNEL_EXPECTED_B2SUM=9c21751cc48ab57cdf48058cc4309752de169c567bbb898c342ff3e4a5cc79add53e3fd4217c2ae2ae7c16b0f19518cf1791907367e1ea9ef16458e1e90c05e0 pass=0 fail=0 @@ -110,6 +112,47 @@ echo echo "Running ka-promote test suite from $repo_root" echo +# ----- unit: strip_trailer + write_cumulative shape (issue #31) ----- +echo "::: strip_trailer + cumulative shape (issue #31)" +python3 - "$repo_root" <<'PY' +import importlib.util, pathlib, sys, tempfile, os +root = pathlib.Path(sys.argv[1]) +from importlib.machinery import SourceFileLoader +mod = SourceFileLoader("ka_promote", str(root/"bin"/"ka-promote")).load_module() + +# strip_trailer accepts both shapes and yields newline-terminated body +assert mod.strip_trailer(b"...body...\n-- \n2.54.0\n\n") == b"...body...\n" +assert mod.strip_trailer(b"...body...\n-- \n2.53.0\n\n") == b"...body...\n" +assert mod.strip_trailer(b"...body...\n-- \n2.20\n\n") == b"...body...\n" +assert mod.strip_trailer(b"...body...\n") == b"...body...\n" +assert mod.strip_trailer(b"...body...") == b"...body...\n" +# Multiple trailing blanks after the version still strip +assert mod.strip_trailer(b"x\n-- \n2.54.0\n\n\n") == b"x\n" + +# write_cumulative: 3 inputs (mix of with-/without-trailer), check ordering +with tempfile.TemporaryDirectory() as d: + p1 = os.path.join(d, "a.patch"); open(p1,"wb").write(b"PA\n-- \n2.54.0\n\n") + p2 = os.path.join(d, "b.patch"); open(p2,"wb").write(b"PB\n") # already bare + p3 = os.path.join(d, "c.patch"); open(p3,"wb").write(b"PC\n-- \n2.40.1\n\n") + out = os.path.join(d, "out.patch") + resolved = [{"src": p1}, {"src": p2}, {"src": p3}] + mod.write_cumulative(resolved, out) + body = open(out,"rb").read() + assert body == b"PA\n-- \n2.54.0\n\nPB\n-- \n2.54.0\n\nPC\n", repr(body) + # Last patch (PC) must NOT carry an orphan trailer at EOF + assert not body.rstrip(b"\n").endswith(b"2.40.1"), \ + f"last patch's trailer leaked into cumulative: {body[-40:]!r}" +print("PASS") +PY +if [ $? -eq 0 ]; then + results+=("PASS strip_trailer + cumulative shape (issue #31)") + pass=$((pass+1)) +else + results+=("FAIL strip_trailer + cumulative shape (issue #31)") + fail=$((fail+1)) +fi +echo + # Use the real fleet/fresnel.yaml — copy into a sandbox so the test is hermetic. mkdir -p /tmp/ka-promote-parity-fixture cp "$repo_root/fleet/fresnel.yaml" /tmp/ka-promote-parity-fixture/fresnel.yaml