ka-promote: auto-normalise git format-patch trailers (closes #31) #32
+39
-2
@@ -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<MAJOR>.<MINOR>(.<PATCH>)?\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 <sha>".
|
||||
_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 <sha>" 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user