fix(lefthook): remove duplicate git-lfs pre-push command
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)
This commit is contained in:
145
scripts/test-lefthook-pre-push.sh
Executable file
145
scripts/test-lefthook-pre-push.sh
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/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."
|
||||
Reference in New Issue
Block a user