Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# <local-ref> <local-oid> <remote-ref> <remote-oid>
#
# 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 ""

Expand Down
129 changes: 129 additions & 0 deletions test/integration/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading