diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 000000000..5952ca25b --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,42 @@ +# Native Git hooks + +`pnpm install` configures `core.hooksPath=.githooks` via +`scripts/install-git-hooks.mjs`. Tracked hooks exist in every linked worktree. + +## `post-checkout` + +### 1. Clear TypeScript project emit (always on branch / worktree checkout) + +`pnpm check` is `tsgo --build` with composite + incremental. Package outputs +(`.tsbuildinfo` + `.d.ts` / `.js`) live under `packages/*/dist` and are +gitignored. + +After a branch switch, **sources** follow HEAD but **dist** can still belong to +another tree. tsgo may treat projects as up to date and typecheck new sources +against old declarations → phantom type errors that disappear on +`pnpm rbuild` (`clean` + check). Same class of failure as +[scanner#1495](https://github.com/macs-holding/scanner/pull/1495) / +[scanner#2166](https://github.com/macs-holding/scanner/pull/2166). + +On every **branch** checkout (`post-checkout` flag `1`), this hook deletes: + +- `packages/*/dist`, `packages/*/build`, root `dist` if present +- any remaining `*.tsbuildinfo` outside `node_modules` / `repos` / `.git` + +File-only checkouts (flag `0`) are left alone. + +**This is a workaround.** Root cause belongs in tsgo incremental invalidation +(e.g. [typescript-go#2666](https://github.com/microsoft/typescript-go/issues/2666), +[#4664](https://github.com/microsoft/typescript-go/issues/4664), +[#4262](https://github.com/microsoft/typescript-go/issues/4262)). Remove when +upstream is reliable. Do not re-enable cross-tree dist reuse without an exact +source-identity key. + +### 2. Reconcile pnpm (when package/lock changed) + +Records package/lockfile state under `node_modules` so T3 handoff does not +repeat an install Git already completed. + +## `pre-commit` / `pre-push` + +lint-staged on commit; agent ship gate on push (see repo AGENTS.md). diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 000000000..e1fb69449 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,82 @@ +#!/bin/sh +# 1) After a branch/worktree checkout: drop TypeScript project-reference emit so +# tsgo --build never typechecks against foreign-tree .tsbuildinfo / .d.ts. +# 2) Reconcile pnpm when package.json / lockfile changed. + +PATH="$HOME/.local/bin:$HOME/.local/share/pnpm:$HOME/.local/share/mise/shims:/run/current-system/sw/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:$PATH" +export PATH + +# post-checkout also runs after file-only checkouts; those are not a tree switch. +[ "${3:-0}" = "1" ] || exit 0 + +wt_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 +[ -f "$wt_root/package.json" ] || exit 0 + +# --- TS build-cache wipe (every branch / worktree checkout) ----------------- +# +# Why: `pnpm check` is `tsgo --build` with composite + incremental. Package +# outputs live in packages/*/dist (gitignored). After checkout/rebase, sources +# belong to HEAD but dist may still be from another branch. tsgo can treat +# projects as up-to-date and typecheck new sources against old .d.ts → phantom +# errors that vanish on `pnpm rbuild` (clean+check). +# +# Same class as macs-holding/scanner#1495 / #2166 and upstream typescript-go +# incremental gaps (#2666, #4664, #4262, #3787). +# +# Workaround only — remove when upstream invalidation is reliable. Do not +# re-introduce foreign dist reuse without an exact source-identity key. +# +clean_ts_project_emit() { + root=$1 + removed=0 + + for d in \ + "$root"/packages/*/dist \ + "$root"/packages/*/build \ + "$root"/dist + do + [ -e "$d" ] || continue + rm -rf "$d" + removed=1 + done + + if [ -d "$root" ]; then + tsbuild_files=$( + find "$root" \ + \( -path "$root/node_modules" -o -path "$root/repos" -o -path "$root/.git" -o -path "$root/.run" -o -path "$root/build" \) -prune -o \ + -type f -name '*.tsbuildinfo' -print 2>/dev/null || true + ) + if [ -n "$tsbuild_files" ]; then + printf '%s\n' "$tsbuild_files" | while IFS= read -r f; do + [ -n "$f" ] || continue + rm -f "$f" + done + removed=1 + fi + fi + + if [ "$removed" = 1 ]; then + echo "post-checkout: cleared TypeScript project emit (dist / tsbuildinfo) under $root" >&2 + echo "post-checkout: workaround for tsgo stale incremental cache after tree switch — see .githooks/README.md (remove when upstream fixed)" >&2 + fi +} + +clean_ts_project_emit "$wt_root" + +# --- pnpm install when lockfile / package.json changed ----------------------- +[ -f "$wt_root/pnpm-lock.yaml" ] || exit 0 +state_file="$wt_root/node_modules/.pnpm-checkout-state" +state=$(cksum "$wt_root/package.json" "$wt_root/pnpm-lock.yaml" | cksum | awk '{print $1 ":" $2}') +if [ -f "$wt_root/node_modules/.modules.yaml" ] && [ "$(cat "$state_file" 2>/dev/null)" = "$state" ]; then + exit 0 +fi +command -v pnpm >/dev/null 2>&1 || { + echo "post-checkout: pnpm not on PATH; node_modules not installed" >&2 + exit 1 +} + +export CI=1 +echo "post-checkout: pnpm install in $wt_root" >&2 +cd "$wt_root" || exit 1 +pnpm install --frozen-lockfile --prefer-offline --config.confirmModulesPurge=false || exit $? +printf '%s\n' "$state" >"$state_file" diff --git a/scripts/hooks/post-checkout.test.sh b/scripts/hooks/post-checkout.test.sh new file mode 100755 index 000000000..f36f7587d --- /dev/null +++ b/scripts/hooks/post-checkout.test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +hook="$root/.githooks/post-checkout" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +git init -q "$work/idempotent-main" +cd "$work/idempotent-main" +git config user.email test@example.com +git config user.name test +mkdir -p .githooks "$work/fake-home/.local/bin" +install -m 0755 "$hook" .githooks/post-checkout +printf '{}\n' >package.json +printf 'lockfileVersion: 9\n' >pnpm-lock.yaml +printf '#!/bin/sh\nroot=$(git rev-parse --show-toplevel)\nmkdir -p "$root/node_modules"\ntouch "$root/node_modules/.modules.yaml"\nprintf x >>"$root/.pnpm-runs"\n' >"$work/fake-home/.local/bin/pnpm" +chmod +x "$work/fake-home/.local/bin/pnpm" +git add .githooks package.json pnpm-lock.yaml +git commit -q -m init +git config core.hooksPath .githooks +HOME="$work/fake-home" git worktree add -q "$work/idempotent-linked" -b idempotent +ref=$(git -C "$work/idempotent-linked" rev-parse HEAD) +HOME="$work/fake-home" git -C "$work/idempotent-linked" -c core.hooksPath=.githooks \ + hook run post-checkout -- "$ref" "$ref" 1 +test "$(wc -c <"$work/idempotent-linked/.pnpm-runs")" = 1 + +# Branch checkout must wipe packages/*/dist. +git init -q "$work/ts-clean-main" +cd "$work/ts-clean-main" +git config user.email test@example.com +git config user.name test +mkdir -p .githooks +install -m 0755 "$hook" .githooks/post-checkout +printf '{}\n' >package.json +printf 'lockfileVersion: 9\n' >pnpm-lock.yaml +mkdir -p node_modules +touch node_modules/.modules.yaml +ts_root=$(pwd) +state=$(cksum "$ts_root/package.json" "$ts_root/pnpm-lock.yaml" | cksum | awk '{print $1 ":" $2}') +printf '%s\n' "$state" >node_modules/.pnpm-checkout-state +printf '#!/bin/sh\necho pnpm-should-not-run >>"$(git rev-parse --show-toplevel)/.pnpm-runs"\n' >"$work/fake-home/.local/bin/pnpm" +chmod +x "$work/fake-home/.local/bin/pnpm" +git add .githooks package.json pnpm-lock.yaml +git commit -q -m init +git config core.hooksPath .githooks +ref=$(git rev-parse HEAD) +mkdir -p packages/effect-app/dist packages/vue/dist +printf 'poison\n' >packages/effect-app/dist/.tsbuildinfo +printf 'poison\n' >packages/effect-app/dist/foo.d.ts +printf 'poison\n' >packages/vue/dist/bar.d.ts +HOME="$work/fake-home" git -c core.hooksPath=.githooks \ + hook run post-checkout -- "$ref" "$ref" 1 +test ! -e packages/effect-app/dist +test ! -e packages/vue/dist +test ! -f .pnpm-runs + +mkdir -p packages/effect-app/dist +printf 'keep\n' >packages/effect-app/dist/keep.d.ts +HOME="$work/fake-home" git -c core.hooksPath=.githooks \ + hook run post-checkout -- "$ref" "$ref" 0 +test -f packages/effect-app/dist/keep.d.ts + +echo "portable worktree hooks passed" diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs index 5cb6894e7..de57ede06 100644 --- a/scripts/install-git-hooks.mjs +++ b/scripts/install-git-hooks.mjs @@ -47,6 +47,15 @@ try { console.error(`install-git-hooks: could not install agent gh shim: ${error?.message ?? error}`) } +for (const name of ["post-checkout", "pre-commit", "pre-push"]) { + const p = path.join(root, ".githooks", name) + try { + chmodSync(p, 0o755) + } catch { + // best-effort when the hook is absent or chmod is unavailable + } +} + try { execFileSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: root }) } catch (error) {