Lefthook v2 runs `git lfs pre-push` internally for pre-push hooks (per docs/usage/features/git-lfs.md; confirmed in internal/run/controller/ lfs.go where the internal handler invokes `git lfs pre-push <remote> <url>` with a buffered `cachedStdin`). Our manual `git-lfs:` command in lefthook.yml was a second invocation against the same remote; the duplicate is directly visible in `LEFTHOOK_VERBOSE=1` output as `[git-lfs] executing hook` (internal) followed by `[lefthook] run: git lfs pre-push` (manual). The previous fix attempt (piped: true, commit1b06804) was based on a wrong understanding of `piped`'s semantics — `piped` controls fail-fast behavior, not stdin routing or sequencing. Default lefthook behavior is already sequential per docs/configuration/parallel.md. That "fix" was placebo; incident 2 (F2 push, zero LFS objects, commit99eedb6) proved it. Phase A investigation: documentary + source confirmation that lefthook owns the LFS pre-push call. Phase B sandbox test against a filesystem remote confirmed the duplicate execution in logs but did NOT reproduce the production hang — likely because the duplicate manual call against a local remote has no LFS server to interact with. A network-y remote (Gitea over SSH/HTTPS) appears to be part of the trigger. Two mechanisms remain plausible (H1: PTY-stdin without EOF in `while read` loop per docs/configuration/use_stdin.md; H4: server-side LFS interaction on the duplicate call). Both are eliminated by the same fix: remove the manual command. LFS uploads continue to work via lefthook's internal handler (verified in sandbox post-fix). Regression coverage: scripts/test-lefthook-pre-push.sh asserts exactly one internal LFS invocation, zero manual ones, and `Uploading LFS objects: 100%` present, against a disposable sandbox. See dev-docs/ADR-LEFTHOOK-LFS-INTEGRATION.md for full context, both misconceptions to prevent regression, and the alternative-scenarios playbook if Phase E ever regresses. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
146 lines
4.6 KiB
Bash
Executable File
146 lines
4.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Smoke test for the lefthook + git-lfs pre-push integration.
|
|
#
|
|
# Builds a disposable sandbox at SANDBOX_DIR, copies the repo's
|
|
# current lefthook.yml + .githooks/ into it, and runs a push that
|
|
# exercises both: lefthook's internal LFS handler and the sync-check
|
|
# user command. Passes when:
|
|
#
|
|
# 1. Push completes within the timeout.
|
|
# 2. Exactly one `[git-lfs] executing hook` line is present in the
|
|
# verbose log (proves no duplicate manual command).
|
|
# 3. `Uploading LFS objects: 100%` is present (proves the internal
|
|
# handler did the upload).
|
|
#
|
|
# Fails loudly otherwise. See dev-docs/ADR-LEFTHOOK-LFS-INTEGRATION.md
|
|
# for what each failure mode signals.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
SANDBOX_DIR="${SANDBOX_DIR:-/tmp/lefthook-lfs-smoke-$$}"
|
|
TIMEOUT_SECS="${TIMEOUT_SECS:-30}"
|
|
|
|
# Resolve the lefthook binary the repo already has installed.
|
|
LEFTHOOK_BIN="${LEFTHOOK_BIN:-}"
|
|
if [ -z "${LEFTHOOK_BIN}" ]; then
|
|
candidate=$(find "${REPO_ROOT}/node_modules" -type f -name lefthook \
|
|
-path '*/lefthook-darwin-arm64/bin/*' 2>/dev/null | head -n 1)
|
|
if [ -z "${candidate}" ]; then
|
|
candidate=$(find "${REPO_ROOT}/node_modules" -type f -name lefthook \
|
|
2>/dev/null | head -n 1)
|
|
fi
|
|
LEFTHOOK_BIN="${candidate}"
|
|
fi
|
|
|
|
if [ -z "${LEFTHOOK_BIN}" ] || [ ! -x "${LEFTHOOK_BIN}" ]; then
|
|
echo "FAIL: could not locate lefthook binary (set LEFTHOOK_BIN explicitly)" >&2
|
|
exit 2
|
|
fi
|
|
|
|
if ! command -v git-lfs >/dev/null 2>&1; then
|
|
echo "FAIL: git-lfs not installed on this host" >&2
|
|
exit 2
|
|
fi
|
|
|
|
cleanup() { rm -rf "${SANDBOX_DIR}"; }
|
|
trap cleanup EXIT
|
|
|
|
echo "[smoke] sandbox: ${SANDBOX_DIR}"
|
|
echo "[smoke] lefthook: ${LEFTHOOK_BIN}"
|
|
|
|
mkdir -p "${SANDBOX_DIR}"
|
|
git init --bare -q "${SANDBOX_DIR}/remote.git"
|
|
git init -q "${SANDBOX_DIR}/work"
|
|
cd "${SANDBOX_DIR}/work"
|
|
git config user.email "smoke@crewli.local"
|
|
git config user.name "smoke"
|
|
git remote add origin "${SANDBOX_DIR}/remote.git"
|
|
|
|
# Mirror the repo's hook layer
|
|
cp "${REPO_ROOT}/lefthook.yml" .
|
|
mkdir .githooks
|
|
cp "${REPO_ROOT}/.githooks/pre-push" .githooks/
|
|
cp "${REPO_ROOT}/.githooks/post-commit" .githooks/
|
|
chmod +x .githooks/*
|
|
|
|
LEFTHOOK_BIN="${LEFTHOOK_BIN}" "${LEFTHOOK_BIN}" install >/dev/null
|
|
git lfs install --skip-repo >/dev/null
|
|
git lfs track "*.png" >/dev/null
|
|
|
|
# Tiny valid PNG so LFS has something to push
|
|
python3 - <<'PY'
|
|
import struct, zlib
|
|
hdr = b'\x89PNG\r\n\x1a\n'
|
|
def chunk(t, d):
|
|
return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d))
|
|
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0))
|
|
idat = chunk(b'IDAT', zlib.compress(b'\x00\xff\xff\xff', 9))
|
|
iend = chunk(b'IEND', b'')
|
|
open('smoke.png', 'wb').write(hdr + ihdr + idat + iend)
|
|
PY
|
|
|
|
git add .gitattributes lefthook.yml .githooks smoke.png
|
|
LEFTHOOK=0 git commit -q -m "smoke test"
|
|
|
|
LOG_FILE="${SANDBOX_DIR}/push.log"
|
|
|
|
# Background push; kill if it exceeds the timeout
|
|
(
|
|
cd "${SANDBOX_DIR}/work"
|
|
LEFTHOOK_BIN="${LEFTHOOK_BIN}" \
|
|
LEFTHOOK_VERBOSE=1 \
|
|
git push -u origin master >"${LOG_FILE}" 2>&1
|
|
echo "EXIT=$?" >>"${LOG_FILE}"
|
|
) &
|
|
PUSH_PID=$!
|
|
|
|
elapsed=0
|
|
while kill -0 "${PUSH_PID}" 2>/dev/null; do
|
|
if [ "${elapsed}" -ge "${TIMEOUT_SECS}" ]; then
|
|
kill -KILL "${PUSH_PID}" 2>/dev/null || true
|
|
pkill -KILL -P "${PUSH_PID}" 2>/dev/null || true
|
|
echo "[smoke] FAIL: push exceeded ${TIMEOUT_SECS}s timeout" >&2
|
|
echo "[smoke] partial log:" >&2
|
|
cat "${LOG_FILE}" >&2 || true
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
elapsed=$((elapsed + 1))
|
|
done
|
|
|
|
wait "${PUSH_PID}" 2>/dev/null || true
|
|
|
|
if ! grep -q '^EXIT=0$' "${LOG_FILE}"; then
|
|
echo "[smoke] FAIL: push exited non-zero" >&2
|
|
cat "${LOG_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Exactly one internal LFS invocation; zero manual ones
|
|
internal_count=$(grep -c '\[git-lfs\] executing hook' "${LOG_FILE}" || true)
|
|
manual_count=$(grep -c '\[lefthook\] run: git lfs pre-push' "${LOG_FILE}" || true)
|
|
upload_marker=$(grep -c 'Uploading LFS objects: 100%' "${LOG_FILE}" || true)
|
|
|
|
if [ "${internal_count}" != "1" ]; then
|
|
echo "[smoke] FAIL: expected 1 internal LFS invocation, found ${internal_count}" >&2
|
|
cat "${LOG_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "${manual_count}" != "0" ]; then
|
|
echo "[smoke] FAIL: a manual 'git lfs pre-push' command is present" \
|
|
"(${manual_count} occurrences). The duplicate-execution regression has returned." >&2
|
|
echo "[smoke] See dev-docs/ADR-LEFTHOOK-LFS-INTEGRATION.md for why this fails." >&2
|
|
cat "${LOG_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "${upload_marker}" = "0" ]; then
|
|
echo "[smoke] FAIL: no 'Uploading LFS objects' marker in log" >&2
|
|
cat "${LOG_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "[smoke] PASS: push completed; 1 internal LFS call, 0 manual, LFS upload confirmed."
|