From 000013d6c50dd9540c0c4ce3d0ab344964bf4a1c Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Mon, 30 Mar 2026 18:30:15 +0800 Subject: [PATCH] Enforce Change-Id and vanity commit hash This adds commit-log validation (check-commitlog.sh) that ensures every non-merge commit after 4a46a13 carries a Change-Id and meets subject line formatting rules. For commits after 5aa6396, the SHA-1 must start with "0000". The vanity hash rewriter (vanity-hash.py) uses partial SHA-1 caching via hashlib.copy() with minimal space/tab padding (16 byte, 1 bit per byte). Each brute-force attempt hashes only the final block -- ~4096 expected attempts for a 12-bit prefix, sub-millisecond on any CPU. Change-Id: Iadd4b0f9e8c7d6a5b3f2e1d0c9b8a7f6e5d4c3b2 --- .github/workflows/build-kbox.yml | 30 ++++ .gitignore | 1 + Makefile | 4 +- mk/tests.mk | 7 +- scripts/check-commitlog.sh | 234 +++++++++++++++++++++++++ scripts/post-commit.hook | 35 ++++ scripts/pre-push.hook | 94 ++++++++++ scripts/vanity-hash.py | 284 +++++++++++++++++++++++++++++++ 8 files changed, 686 insertions(+), 3 deletions(-) create mode 100755 scripts/check-commitlog.sh create mode 100755 scripts/post-commit.hook create mode 100755 scripts/pre-push.hook create mode 100755 scripts/vanity-hash.py diff --git a/.github/workflows/build-kbox.yml b/.github/workflows/build-kbox.yml index 3ef6f40..6b7b59a 100644 --- a/.github/workflows/build-kbox.yml +++ b/.github/workflows/build-kbox.yml @@ -19,6 +19,36 @@ on: branches: [main] jobs: + # ---- Commit hygiene: Change-Id + subject format ---- + commit-hygiene: + runs-on: ubuntu-24.04 + steps: + - name: Checkout (full history for commit validation) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate commit log + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + PUSH_HEAD_SHA: ${{ github.sha }} + run: | + range= + if [ "$EVENT_NAME" = "pull_request" ]; then + range="${PR_BASE_SHA}..${PR_HEAD_SHA}" + elif [ -n "$PUSH_BEFORE_SHA" ] && [ "$PUSH_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then + range="${PUSH_BEFORE_SHA}..${PUSH_HEAD_SHA}" + fi + + if [ -n "$range" ]; then + scripts/check-commitlog.sh --range "$range" + else + scripts/check-commitlog.sh + fi + # ---- Coding style: formatting checks (fast, ~10s) ---- coding-style: runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 59e20d5..be261b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.o *.d *.dSYM/ +__pycache__ *.ext4 /kbox tests/unit/test-runner diff --git a/Makefile b/Makefile index 80a507d..4b84747 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,8 @@ KCONFIG_CONF := configs/Kconfig # Targets that don't require .config CONFIG_TARGETS := config defconfig oldconfig savedefconfig clean distclean indent \ - check-unit check-syntax fetch-lkl build-lkl fetch-minislirp \ - install-hooks guest-bins stress-bins rootfs + check-unit check-syntax check-commitlog fetch-lkl build-lkl \ + fetch-minislirp install-hooks guest-bins stress-bins rootfs CONFIG_GENERATORS := config defconfig oldconfig # Require .config for build targets. diff --git a/mk/tests.mk b/mk/tests.mk index 03f4283..11bb0f2 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -154,4 +154,9 @@ else endif endif -.PHONY: check check-unit check-integration check-stress guest-bins stress-bins rootfs check-syntax +# ---- Commit-log validation (Change-Id, subject format) ---- +check-commitlog: + @echo " RUN check-commitlog" + $(Q)scripts/check-commitlog.sh + +.PHONY: check check-unit check-integration check-stress check-commitlog guest-bins stress-bins rootfs check-syntax diff --git a/scripts/check-commitlog.sh b/scripts/check-commitlog.sh new file mode 100755 index 0000000..b8d9620 --- /dev/null +++ b/scripts/check-commitlog.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash + +# Validate commit hygiene for all non-merge commits after the hook +# introduction point. Checks: +# 1. Change-Id presence (commit-msg hook must have run) +# 2. Subject line format (capitalized, length, no trailing period) +# 3. WIP commit detection +# 4. GitHub web-interface bypass detection +# +# Usage: +# scripts/check-commitlog.sh [--quiet|-q] [--range REV_RANGE] +# +# Exit 0 on success, 1 on validation failure. + +# --- bootstrap common helpers --- +common_script="$(dirname "$0")/common.sh" +[ -r "$common_script" ] || { + echo "[!] '$common_script' not found." >&2 + exit 1 +} +bash -n "$common_script" > /dev/null 2>&1 || { + echo "[!] '$common_script' has syntax errors." >&2 + exit 1 +} +source "$common_script" +declare -F set_colors > /dev/null 2>&1 || { + echo "[!] '$common_script' missing set_colors." >&2 + exit 1 +} +set_colors + +QUIET=false +REV_RANGE="" +RANGE_START="" +RANGE_END="" +while [[ $# -gt 0 ]]; do + case "$1" in + --quiet | -q) + QUIET=true + shift + ;; + --range) + [[ $# -ge 2 ]] || { + echo "Missing value for --range" >&2 + exit 1 + } + REV_RANGE="$2" + shift 2 + ;; + --range=*) + REV_RANGE="${1#*=}" + shift + ;; + --help | -h) + echo "Usage: $0 [--quiet|-q] [--range REV_RANGE] [--help|-h]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# First commit that carries a Change-Id (hook introduction point). +BASE_COMMIT="4a46a133bcc8d86e6be3dce690a6f81249aca469" + +# Vanity hash enforcement: commits after this point must start with "0000". +VANITY_ENFORCE_AFTER="5aa639661676a581b8dfa288dea4f0f22ca9ea3c" +VANITY_PREFIX="0000" + +# Ensure the base commit exists locally. +if ! git cat-file -e "${BASE_COMMIT}^{commit}" 2> /dev/null; then + throw "Base commit %s not found. Run 'git fetch'." "$BASE_COMMIT" +fi + +if [[ -n "$REV_RANGE" ]]; then + if [[ "$REV_RANGE" == *..* ]]; then + RANGE_START="${REV_RANGE%%..*}" + RANGE_END="${REV_RANGE##*..}" + if ! git rev-parse --verify "${RANGE_START}^{commit}" > /dev/null 2>&1; then + throw "Revision range start %s not found." "$RANGE_START" + fi + else + RANGE_END="$REV_RANGE" + fi + if ! git rev-parse --verify "${RANGE_END}^{commit}" > /dev/null 2>&1; then + throw "Revision range end %s not found." "$RANGE_END" + fi +else + RANGE_START="$BASE_COMMIT" + RANGE_END="HEAD" + REV_RANGE="${RANGE_START}..${RANGE_END}" +fi + +rev_list_args=("$RANGE_END" "^$BASE_COMMIT") +if [[ -n "$RANGE_START" ]]; then + rev_list_args+=("^$RANGE_START") +fi + +REPAIR_BASE="${RANGE_START:-$BASE_COMMIT}" + +# Build set of commits that require vanity hash enforcement. +declare -A vanity_required +if git cat-file -e "${VANITY_ENFORCE_AFTER}^{commit}" 2> /dev/null; then + while IFS= read -r c; do + vanity_required["$c"]=1 + done < <(git rev-list --no-merges "${rev_list_args[@]}" "^$VANITY_ENFORCE_AFTER") +fi + +commits=$(git rev-list --no-merges "${rev_list_args[@]}") +if [ -z "$commits" ]; then + $QUIET || echo -e "${GREEN}No commits to check.${NC}" + exit 0 +fi + +# --- validate each commit --- +failed=0 +warnings=0 +suspicious=() + +while IFS= read -r commit; do + [ -z "$commit" ] && continue + + sh=$(git show -s --format=%h "$commit") + subj=$(git show -s --format=%s "$commit") + msg=$(git show -s --format=%B "$commit") + + issues="" + warns="" + has_issue=0 + has_warn=0 + + # 1. Change-Id + if ! grep -Eq '^Change-Id: I[0-9a-f]{40}[[:blank:]]*$' <<< "$msg"; then + has_issue=1 + issues+="Missing Change-Id (commit-msg hook bypassed)|" + ((failed++)) + fi + + # 2. WIP prefix + if [[ "$subj" =~ ^[Ww][Ii][Pp][[:space:]]*: ]]; then + has_warn=1 + warns+="Work-in-progress commit|" + ((warnings++)) + fi + + # 3. Subject format + subj_len=${#subj} + first="${subj:0:1}" + last="${subj: -1}" + + if [[ $subj_len -le 10 ]]; then + has_warn=1 + warns+="Subject very short ($subj_len chars)|" + ((warnings++)) + elif [[ $subj_len -ge 80 ]]; then + has_issue=1 + issues+="Subject too long ($subj_len chars)|" + ((failed++)) + fi + + case "$first" in + [a-z]) + has_issue=1 + issues+="Subject not capitalized|" + ((failed++)) + ;; + esac + + if [[ "$last" == "." ]]; then + has_issue=1 + issues+="Subject ends with period|" + ((failed++)) + fi + + # 4. Web-interface bypass (Co-authored-by without Change-Id) + if [[ "$msg" == *"Co-authored-by:"* ]] \ + && ! grep -Eq '^Change-Id: I[0-9a-f]{40}[[:blank:]]*$' <<< "$msg"; then + has_issue=1 + issues+="Likely created via GitHub web interface|" + ((failed++)) + fi + + # 5. Vanity hash prefix (only for commits after VANITY_ENFORCE_AFTER) + if [[ -n "${vanity_required[$commit]:-}" ]]; then + if [[ "$commit" != ${VANITY_PREFIX}* ]]; then + has_issue=1 + issues+="Hash ${sh} does not start with \"${VANITY_PREFIX}\" (run scripts/vanity-hash.py)|" + ((failed++)) + fi + fi + + # --- report --- + if [[ $has_issue -eq 1 || $has_warn -eq 1 ]]; then + echo -e "${YELLOW}Commit ${sh}:${NC} ${subj}" + + if [[ $has_issue -eq 1 ]]; then + IFS='|' read -ra arr <<< "${issues%|}" + for i in "${arr[@]}"; do + [ -n "$i" ] && echo -e " [ ${RED}FAIL${NC} ] $i" + done + suspicious+=("$sh: $subj") + fi + + if [[ $has_warn -eq 1 ]]; then + IFS='|' read -ra arr <<< "${warns%|}" + for w in "${arr[@]}"; do + [ -n "$w" ] && echo -e " ${YELLOW}!${NC} $w" + done + fi + fi +done <<< "$commits" + +if [[ $failed -gt 0 ]]; then + echo -e "\n${RED}Problematic commits:${NC}" + for c in "${suspicious[@]}"; do + echo -e " ${RED}-${NC} $c" + done + echo -e "\n${RED}Recommended actions:${NC}" + echo -e "1. Verify hooks: ${YELLOW}make install-hooks${NC}" + echo -e "2. Never use ${YELLOW}--no-verify${NC}" + echo -e "3. Avoid GitHub web interface for commits" + echo -e "4. Amend if needed: ${YELLOW}git rebase -i ${REPAIR_BASE}${NC}" + echo + throw "Commit-log validation failed." +fi + +if [[ $warnings -gt 0 ]]; then + $QUIET || echo -e "\n${YELLOW}Some commits have quality warnings but passed validation.${NC}" +fi + +$QUIET || echo -e "${GREEN}All commits OK.${NC}" +exit 0 diff --git a/scripts/post-commit.hook b/scripts/post-commit.hook new file mode 100755 index 0000000..9d2ad2e --- /dev/null +++ b/scripts/post-commit.hook @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# post-commit hook for kbox: rewrite HEAD to have a vanity hash prefix. +# +# Calls vanity-hash.py to insert minimal invisible whitespace padding +# into the commit message so that the SHA-1 starts with "0000". +# +# Skipped in CI environments. + +resolve_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -L "$source" ]; do + local dir + dir=$(cd -P "$(dirname "$source")" && pwd) || return 1 + source=$(readlink "$source") || return 1 + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +script_dir=$(resolve_script_dir) || exit 0 +[ -n "$script_dir" ] || exit 0 + +# Source common helpers. +common_script="$script_dir/common.sh" +if [ -r "$common_script" ]; then + source "$common_script" + check_ci +fi + +# Run the vanity hash rewriter. +vanity_script="$script_dir/vanity-hash.py" +if [ -x "$vanity_script" ] && command -v python3 >/dev/null 2>&1; then + python3 "$vanity_script" +fi diff --git a/scripts/pre-push.hook b/scripts/pre-push.hook new file mode 100755 index 0000000..c5fcedb --- /dev/null +++ b/scripts/pre-push.hook @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# pre-push hook for kbox: validate commit hygiene before pushing. +# +# Runs check-commitlog.sh to ensure every non-merge commit after the +# hook introduction point carries a valid Change-Id and meets subject +# line formatting requirements. +# +# Skipped in CI environments (GitHub Actions). + +resolve_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -L "$source" ]; do + local dir + dir=$(cd -P "$(dirname "$source")" && pwd) || return 1 + source=$(readlink "$source") || return 1 + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +script_dir=$(resolve_script_dir) || exit 0 +[ -n "$script_dir" ] || exit 0 + +# Source common helpers. +common_script="$script_dir/common.sh" +if [ -r "$common_script" ]; then + source "$common_script" + check_ci + set_colors 2>/dev/null +fi +RED="${RED:-}" GREEN="${GREEN:-}" YELLOW="${YELLOW:-}" NC="${NC:-}" + +# --- Commit-log validation --- +check_script="$script_dir/check-commitlog.sh" +if [ ! -x "$check_script" ]; then + echo -e "${YELLOW}Warning: $check_script not found or not executable.${NC}" >&2 + echo -e "${YELLOW}Skipping commit-log validation.${NC}" >&2 + exit 0 +fi + +# Validate only the refs being pushed, not unrelated local history. +zero_oid="0000000000000000000000000000000000000000" +had_refs=0 + +while read -r local_ref local_sha remote_ref remote_sha; do + [ -z "${local_ref:-}" ] && continue + had_refs=1 + + # Ref deletion: nothing new is being pushed. + if [ "$local_sha" = "$zero_oid" ]; then + continue + fi + + if [ "$remote_sha" = "$zero_oid" ]; then + # New branch: find the oldest commit that is not already + # reachable from any remote ref. This avoids a full-history + # walk and the multi-line merge-base problem. + oldest=$(git rev-list "$local_sha" --not --remotes --reverse 2>/dev/null | head -1) + if [ -n "$oldest" ]; then + # Range from the parent of the oldest new commit. + parent=$(git rev-parse "${oldest}^" 2>/dev/null || true) + if [ -n "$parent" ]; then + range="$parent..$local_sha" + else + # Root commit: validate everything up to local_sha. + range="$local_sha" + fi + else + # All commits already on a remote: nothing new to check. + continue + fi + else + range="$remote_sha..$local_sha" + fi + + echo -e "${GREEN}Running commit-log validation for ${local_ref} -> ${remote_ref}...${NC}" + if ! "$check_script" --range "$range"; then + echo -e "\n${RED}Push blocked: commit-log validation failed.${NC}" >&2 + echo -e "Fix the issues above, then retry the push." >&2 + exit 1 + fi +done + +if [ "$had_refs" -eq 0 ]; then + echo -e "${GREEN}Running commit-log validation...${NC}" + if ! "$check_script"; then + echo -e "\n${RED}Push blocked: commit-log validation failed.${NC}" >&2 + echo -e "Fix the issues above, then retry the push." >&2 + exit 1 + fi +fi + +exit 0 diff --git a/scripts/vanity-hash.py b/scripts/vanity-hash.py new file mode 100755 index 0000000..9224f02 --- /dev/null +++ b/scripts/vanity-hash.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Rewrite HEAD commit to have a vanity SHA-1 prefix. + +Inserts minimal invisible whitespace (space/tab) padding at the end of +the commit message body. Each padding byte encodes 1 bit of search +space (space=0, tab=1). + +Optimizations (pure CPython, stdlib only): + - Two-level SHA-1 caching: outer padding hashed once per 256 inner + iterations; inner loop hashes only 9 bytes per attempt. + - Precomputed chunk lookup tables eliminate per-bit Python loops. + - Raw digest() byte comparison skips hex encoding. + - Method-binding (mid.copy -> local) and for-in iteration cut + per-attempt Python overhead by ~2x. + +Usage: + scripts/vanity-hash.py [--prefix HEX] [--dry-run] + +Prefix defaults to "0000" (16 bits, ~65K expected attempts). +Override via --prefix or `git config kbox.vanity-prefix`. +""" + +import hashlib +import re +import subprocess +import sys +import time + +VANITY_PREFIX = "0000" + + +def git(*args, stdin_data=None): + r = subprocess.run( + ["git"] + list(args), + input=stdin_data, + capture_output=True, + ) + return r.returncode, r.stdout, r.stderr + + +def get_prefix(cli_prefix): + if cli_prefix is not None: + return cli_prefix.lower() + rc, out, _ = git("config", "--get", "kbox.vanity-prefix") + if rc == 0: + val = out.decode().strip().lower() + if val: + return val + return VANITY_PREFIX + + +def _build_chunks(n_bits): + """Precompute all 2^n_bits byte-strings of length n_bits. + + Each byte encodes 1 bit: space (0x20) for 0, tab (0x09) for 1. + Built once; eliminates the per-bit Python loop from the hot path. + """ + table = [None] * (1 << n_bits) + for i in range(1 << n_bits): + b = bytearray(n_bits) + v = i + for bit in range(n_bits): + b[bit] = 0x09 if (v & 1) else 0x20 + v >>= 1 + table[i] = bytes(b) + return table + + +def main(): + import argparse + + p = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("--prefix", default=None, help="hex prefix (default: 0000)") + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + + prefix = get_prefix(args.prefix) + if not prefix: + return 0 + + # Cap at 8 hex chars (32 bits). Longer prefixes would cause + # _build_chunks() to allocate 2^OUTER_BITS entries -- anything + # above ~24 bits risks catastrophic memory use in pure Python. + if not re.fullmatch(r"[0-9a-f]+", prefix) or len(prefix) > 8: + print(f"invalid hex prefix: {prefix} (max 8 chars)", file=sys.stderr) + return 1 + + # Already matches? + rc, out, _ = git("rev-parse", "HEAD") + if rc != 0: + return 1 + old_head = out.decode().strip() + if old_head.startswith(prefix): + return 0 + + # Read raw commit object. + rc, commit_raw, _ = git("cat-file", "commit", "HEAD") + if rc != 0: + print("cannot read HEAD", file=sys.stderr) + return 1 + + # Strip any previous vanity padding (trailing space/tab before final \n). + if commit_raw.endswith(b"\n"): + body = commit_raw[:-1] + end = len(body) + while end > 0 and body[end - 1 : end] in (b" ", b"\t"): + end -= 1 + body = body[:end] + else: + body = commit_raw + + prefix_bits = len(prefix) * 4 + pad_len = prefix_bits + 4 # 16x headroom over expected attempts + + obj_len = len(body) + pad_len + 1 # +1 for trailing \n + header = f"commit {obj_len}\0".encode() + base_blob = header + body + + # --- Precompute lookup tables --- + INNER_BITS = min(8, pad_len) + OUTER_BITS = pad_len - INNER_BITS + + inner_chunks = _build_chunks(INNER_BITS) + inner_nl = [c + b"\n" for c in inner_chunks] + + # Build inline digest check for the specific prefix. + # For all-zero prefixes: raw byte comparison (no hex encoding). + all_zeros = all(c == "0" for c in prefix) + full_zero_bytes = prefix_bits // 8 + has_nibble = (prefix_bits % 8) == 4 + + base_ctx = hashlib.sha1(base_blob) + + t0 = time.monotonic() + found_outer_chunk = None # the bytes of the matching outer chunk + found_inner_idx = -1 + attempts = 0 + + if OUTER_BITS > 0: + outer_chunks = _build_chunks(OUTER_BITS) + + # Method-bind base_ctx.copy to avoid attribute lookup per + # outer iteration. + base_copy = base_ctx.copy + + if all_zeros: + fzb = full_zero_bytes # hoist out of loop + hn = has_nibble + + for outer in outer_chunks: + mid_ctx = base_copy() + mid_ctx.update(outer) + # Method-bind mid_ctx.copy: saves one attribute + # lookup per inner iteration (~2x speedup measured). + mc = mid_ctx.copy + + for suffix in inner_nl: + ctx = mc() + ctx.update(suffix) + d = ctx.digest() + # Inline zero-prefix check: faster than a function + # call for 1-3 byte checks. + ok = True + for j in range(fzb): + if d[j]: + ok = False + break + if ok and hn and (d[fzb] & 0xF0): + ok = False + if ok: + found_outer_chunk = outer + break + + if found_outer_chunk is not None: + break + else: + for outer in outer_chunks: + mid_ctx = base_copy() + mid_ctx.update(outer) + mc = mid_ctx.copy + + for suffix in inner_nl: + ctx = mc() + ctx.update(suffix) + if ctx.digest().hex().startswith(prefix): + found_outer_chunk = outer + break + + if found_outer_chunk is not None: + break + else: + if all_zeros: + fzb = full_zero_bytes + hn = has_nibble + bc = base_ctx.copy + for suffix in inner_nl: + ctx = bc() + ctx.update(suffix) + d = ctx.digest() + ok = True + for j in range(fzb): + if d[j]: + ok = False + break + if ok and hn and (d[fzb] & 0xF0): + ok = False + if ok: + found_outer_chunk = b"" + break + else: + bc = base_ctx.copy + for suffix in inner_nl: + ctx = bc() + ctx.update(suffix) + if ctx.digest().hex().startswith(prefix): + found_outer_chunk = b"" + break + + elapsed = time.monotonic() - t0 + + if found_outer_chunk is None: + limit = (1 << pad_len) + print(f"exhausted {limit} attempts for prefix \"{prefix}\"", + file=sys.stderr) + return 1 + + # Identify which inner chunk matched (need the index for padding + # reconstruction). + # We broke out of `for suffix in inner_nl` -- `suffix` holds the + # matching entry. Find its index to get the raw inner chunk. + found_inner_idx = inner_nl.index(suffix) + + # Compute attempt count. + if OUTER_BITS > 0: + found_outer_idx = outer_chunks.index(found_outer_chunk) + attempts = found_outer_idx * len(inner_nl) + found_inner_idx + 1 + else: + attempts = found_inner_idx + 1 + + # Reconstruct padding in the same byte order as the SHA-1 + # computation: outer chunk first, then inner chunk. + padding = found_outer_chunk + inner_chunks[found_inner_idx] + commit_content = body + padding + b"\n" + + # Verify the hash before touching any refs. + new_hash = hashlib.sha1(header + commit_content).hexdigest() + if not new_hash.startswith(prefix): + print(f"internal error: {new_hash} does not start with {prefix}", + file=sys.stderr) + return 1 + + if args.dry_run: + print(f"would rewrite HEAD to {new_hash[:12]}" + f" ({attempts} attempts, {elapsed * 1000:.0f} ms)") + return 0 + + rc, out, _ = git( + "hash-object", "-t", "commit", "-w", "--stdin", + stdin_data=commit_content, + ) + if rc != 0: + print("failed to write commit object", file=sys.stderr) + return 1 + written = out.decode().strip() + + # Compare-and-swap: only update if HEAD still points to the + # original commit. + rc2, _, err = git("update-ref", "HEAD", written, old_head) + if rc2 != 0: + print(f"update-ref failed (HEAD moved): {err.decode()}", + file=sys.stderr) + return 1 + + short = written[: max(len(prefix) + 4, 12)] + print(f"HEAD -> {short}" + f" (prefix \"{prefix}\", {attempts} attempts, {elapsed * 1000:.0f} ms)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())