diff --git a/.husky/pre-push b/.husky/pre-push index a10fb62..939645b 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -13,6 +13,30 @@ unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG GIT_CONFIG_PARAMETERS \ GIT_NO_REPLACE_OBJECTS GIT_REPLACE_REF_BASE GIT_PREFIX \ GIT_SHALLOW_FILE GIT_COMMON_DIR +# Git sends one line per ref being pushed on stdin: +# +# +# A tag push carries no new code. The commit a tag points at was already checked +# when the branch holding it was pushed, and again by CI, so re-running the full +# build + integration suite here costs minutes during a release and can never +# produce a different answer. Skip only when EVERY ref is a tag; a push carrying +# any branch ref, or one we cannot classify, runs the checks as before. +refs_total=0 +tag_refs=0 +while read -r _local_ref _local_oid remote_ref _remote_oid; do + [ -z "$remote_ref" ] && continue + refs_total=$((refs_total + 1)) + case "$remote_ref" in + refs/tags/*) tag_refs=$((tag_refs + 1)) ;; + esac +done + +if [ "$refs_total" -gt 0 ] && [ "$refs_total" -eq "$tag_refs" ]; then + echo "⏭️ Tag-only push ($tag_refs ref(s)): skipping pre-push checks." + echo " The tagged commit was already verified when its branch was pushed." + exit 0 +fi + echo "🔍 Running pre-push checks..." echo "" diff --git a/test/integration/hooks.test.ts b/test/integration/hooks.test.ts index ad6a350..51c7d4b 100644 --- a/test/integration/hooks.test.ts +++ b/test/integration/hooks.test.ts @@ -1139,3 +1139,132 @@ describe("hooksStatus command", () => { } }) }) + +// ============================================================================= +// Pre-push Hook: tag-only push guard +// ============================================================================= + +describe("pre-push hook ref filtering", () => { + let testDir: string + + /** + * Run the real .husky/pre-push against a stubbed scripts/check.sh. + * + * The stub records that it ran instead of executing the real build and + * integration suite, so a regression in the ref guard fails in milliseconds + * rather than kicking off a multi-minute check run inside the test. + */ + const runPrePush = (stdin: string): { status: number; checksRan: boolean; output: string } => { + const marker = resolve(testDir, "check-ran") + mkdirSync(resolve(testDir, "scripts"), { recursive: true }) + writeFileSync( + resolve(testDir, "scripts", "check.sh"), + `#!/bin/sh\ntouch "${marker}"\nexit 0\n`, + ) + chmodSync(resolve(testDir, "scripts", "check.sh"), 0o755) + + let status = 0 + let output = "" + try { + output = execSync(`sh "${resolve(testDir, "pre-push")}" origin https://example.invalid/repo.git`, { + cwd: testDir, + input: stdin, + encoding: "utf-8", + timeout: 30000, + }) + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string } + status = e.status ?? 1 + output = `${e.stdout ?? ""}${e.stderr ?? ""}` + } + + return { status, checksRan: existsSync(marker), output } + } + + beforeEach(() => { + testDir = createTestDir("pre-push-refs", false) + mkdirSync(testDir, { recursive: true }) + writeFileSync( + resolve(testDir, "pre-push"), + readFileSync(resolve(__dirname, "../../.husky/pre-push"), "utf-8"), + ) + }) + + afterEach(() => { + cleanupTestDir(testDir) + }) + + const SHA_A = "1111111111111111111111111111111111111111" + const SHA_B = "2222222222222222222222222222222222222222" + + it("skips checks for a tag-only push", () => { + const result = runPrePush(`refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + expect(result.output).toContain("Tag-only push") + }) + + it("skips checks when several tags are pushed at once", () => { + const result = runPrePush( + `refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n` + + `refs/tags/v1.2.4 ${SHA_A} refs/tags/v1.2.4 ${SHA_B}\n`, + ) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + }) + + it("skips checks for a tag deletion", () => { + const zero = "0000000000000000000000000000000000000000" + const result = runPrePush(`(delete) ${zero} refs/tags/v1.2.3 ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(false) + }) + + it("runs checks for a branch push", () => { + const result = runPrePush(`refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n`) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("runs checks when a push mixes a branch and a tag", () => { + const result = runPrePush( + `refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n` + + `refs/tags/v1.2.3 ${SHA_A} refs/tags/v1.2.3 ${SHA_B}\n`, + ) + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("runs checks when stdin carries no refs", () => { + // Never skip on an input shape we do not understand. + const result = runPrePush("") + + expect(result.status).toBe(0) + expect(result.checksRan).toBe(true) + }) + + it("still blocks the push when checks fail on a branch push", () => { + mkdirSync(resolve(testDir, "scripts"), { recursive: true }) + writeFileSync(resolve(testDir, "scripts", "check.sh"), "#!/bin/sh\nexit 1\n") + chmodSync(resolve(testDir, "scripts", "check.sh"), 0o755) + + let status = 0 + try { + execSync(`sh "${resolve(testDir, "pre-push")}" origin https://example.invalid/repo.git`, { + cwd: testDir, + input: `refs/heads/main ${SHA_A} refs/heads/main ${SHA_B}\n`, + encoding: "utf-8", + timeout: 30000, + }) + } catch (err) { + status = (err as { status?: number }).status ?? 1 + } + + expect(status).toBe(1) + }) +})