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
42 changes: 42 additions & 0 deletions .githooks/README.md
Original file line number Diff line number Diff line change
@@ -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).
82 changes: 82 additions & 0 deletions .githooks/post-checkout
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 64 additions & 0 deletions scripts/hooks/post-checkout.test.sh
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 9 additions & 0 deletions scripts/install-git-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading