mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Bare mktemp, mktemp -t and literal /tmp templates in the learnings,
question and preference logs (plus jsonl-merge, distill, the two
dashboards, /ship's PR body and /document-release's run dir) now use
"${TMPDIR:-/tmp}/<name>.XXXXXX", the form the INV-3 lint accepts. When
the temp file cannot be created, the learnings, question and preference
logs now say the entry was not recorded and how to fix TMPDIR instead of
exiting with only mktemp's own error. Three tests that pinned the bare
PR_BODY_FILE=$(mktemp) and one that pinned the /tmp run dir migrate.
GNU and BSD mktemp already honor TMPDIR without a template, so the
macOS-sandbox drop in #2952 is unverified here; the template makes the
temp location explicit on every mktemp.
Co-authored-by: Davon Johnson <d.johnson@dayofshow.net>
106 lines
3.8 KiB
Bash
Executable File
106 lines
3.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-jsonl-merge — git merge driver for append-only JSONL files.
|
|
#
|
|
# Usage (called by git, not by users):
|
|
# gstack-jsonl-merge <base> <ours> <theirs>
|
|
#
|
|
# Registered in local git config by bin/gstack-artifacts-init and
|
|
# bin/gstack-brain-restore:
|
|
# git config merge.jsonl-append.driver \
|
|
# "$GSTACK_BIN/gstack-jsonl-merge %O %A %B"
|
|
#
|
|
# Behavior:
|
|
# Concatenate base + ours + theirs, dedup exact-duplicate lines, sort by
|
|
# ISO "ts" field when present, fall back to SHA-256 of the line for
|
|
# deterministic order. Write result to <ours> (the %A file per the git
|
|
# merge-driver contract).
|
|
#
|
|
# Two machines appending to the same JSONL file between pushes produces
|
|
# a same-line conflict at the file tail. This driver resolves it cleanly:
|
|
# both appends survive, ordered by wall-clock timestamp where available,
|
|
# content hash otherwise.
|
|
#
|
|
# Exit codes:
|
|
# 0 — merge succeeded, result written to <ours>
|
|
# 1 — error; git treats as conflict and stops the merge
|
|
|
|
# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
|
|
# pipe in the forked child before exec, with no reader on the other end. On
|
|
# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
|
|
# body >=512B blocks write() forever and the script hangs at startup with no
|
|
# output. Compat level 50 restores the tempfile path. These scripts are
|
|
# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
|
|
# guard is per-script, and it survives `bash script.sh` call sites that
|
|
# bypass the shebang.
|
|
BASH_COMPAT=50
|
|
|
|
set -uo pipefail
|
|
|
|
if [ "$#" -lt 3 ]; then
|
|
echo "gstack-jsonl-merge: expected 3 args (base ours theirs), got $#" >&2
|
|
exit 1
|
|
fi
|
|
|
|
BASE="$1"
|
|
OURS="$2"
|
|
THEIRS="$3"
|
|
|
|
TMP=$(mktemp "${TMPDIR:-/tmp}/gstack-jsonl-merge.XXXXXX") || exit 1
|
|
trap 'rm -f "$TMP" 2>/dev/null || true' EXIT
|
|
|
|
python3 - "$BASE" "$OURS" "$THEIRS" > "$TMP" <<'PYEOF'
|
|
import sys, json, hashlib
|
|
|
|
paths = sys.argv[1:4] # base, ours, theirs
|
|
seen = {} # line content -> sort_key
|
|
|
|
for path in paths:
|
|
try:
|
|
with open(path, 'r', encoding='utf-8') as f:
|
|
for line in f:
|
|
line = line.rstrip('\n')
|
|
if not line:
|
|
continue
|
|
if line in seen:
|
|
continue
|
|
# Prefer ISO ts field for sort; fall back to SHA-256. The line
|
|
# content is the final tiebreaker so the order is total: two
|
|
# entries sharing a ts must resolve identically regardless of
|
|
# which side they arrive on. Without it, equal-ts entries fall
|
|
# back to insertion order (base, ours, theirs), and since ours
|
|
# and theirs are swapped depending on which machine runs the
|
|
# merge, the two sides produce divergent files that never
|
|
# converge.
|
|
sort_key = None
|
|
try:
|
|
obj = json.loads(line)
|
|
ts = obj.get('ts') or obj.get('timestamp')
|
|
if isinstance(ts, str):
|
|
sort_key = (0, ts, line)
|
|
except (json.JSONDecodeError, ValueError, TypeError):
|
|
pass
|
|
if sort_key is None:
|
|
h = hashlib.sha256(line.encode('utf-8')).hexdigest()
|
|
sort_key = (1, h, line)
|
|
seen[line] = sort_key
|
|
except FileNotFoundError:
|
|
# Absent base / absent ours / absent theirs are all valid.
|
|
continue
|
|
except OSError:
|
|
# Permission / IO errors are fatal — caller sees non-zero exit.
|
|
sys.exit(1)
|
|
|
|
# Timestamp-ordered entries first (group 0), then hash-ordered (group 1).
|
|
for line, _ in sorted(seen.items(), key=lambda item: item[1]):
|
|
print(line)
|
|
PYEOF
|
|
|
|
_PYEXIT=$?
|
|
if [ "$_PYEXIT" != "0" ]; then
|
|
exit 1
|
|
fi
|
|
|
|
mv "$TMP" "$OURS" || exit 1
|
|
trap - EXIT
|
|
exit 0
|