From 95e177106bf41d1a0050ef4438a17a7993166d93 Mon Sep 17 00:00:00 2001 From: Stack Test Date: Mon, 3 Aug 2026 14:42:01 +0200 Subject: [PATCH 1/3] refactor: use repository-owned native git hooks --- .githooks/README.md | 9 ++ {scripts/hooks => .githooks}/post-checkout | 22 ++--- {.husky => .githooks}/pre-commit | 0 {.husky => .githooks}/pre-push | 0 .../SourceControlRepositoryService.test.ts | 17 +++- .../SourceControlRepositoryService.ts | 23 +++++ package.json | 1 - pnpm-lock.yaml | 10 --- scripts/hooks/post-checkout.test.sh | 41 +++------ scripts/install-git-hooks.mjs | 89 ++----------------- 10 files changed, 78 insertions(+), 134 deletions(-) create mode 100644 .githooks/README.md rename {scripts/hooks => .githooks}/post-checkout (83%) rename {.husky => .githooks}/pre-commit (100%) rename {.husky => .githooks}/pre-push (100%) diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 000000000000..ea25649325ce --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,9 @@ +# Native Git hooks + +`pnpm install` configures `core.hooksPath=.githooks`. The tracked hooks exist +immediately in every linked worktree; `post-checkout` reconciles dependencies +after branch checkout and worktree creation before Git returns. T3 configures +the hook path and triggers that checkout once for a fresh clone, before exposing +the repository to the user. + +Lefthook may later orchestrate parallel jobs, but is intentionally unused now. diff --git a/scripts/hooks/post-checkout b/.githooks/post-checkout similarity index 83% rename from scripts/hooks/post-checkout rename to .githooks/post-checkout index 45c47a805152..b2f6d7e57ef4 100755 --- a/scripts/hooks/post-checkout +++ b/.githooks/post-checkout @@ -1,5 +1,5 @@ #!/bin/sh -# Prepare a linked git worktree after `git worktree add`. +# Reconcile packages after branch checkout and prepare linked git worktrees. # # Canonical hook: scripts/install-git-hooks.mjs installs this into the shared # hooks dir (core.hooksPath) on EVERY host via `pnpm install`, so a raw @@ -19,20 +19,19 @@ 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 -# In the main worktree, git-dir == common-dir; in linked worktrees they differ. -git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 -common_dir=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd) || exit 0 -[ "$git_dir" = "$common_dir" ] && exit 0 +# post-checkout also runs after file-only checkouts; those do not change packages. +[ "${3:-0}" = "1" ] || exit 0 main_wt=$(git worktree list --porcelain | sed -n '1s/^worktree //p') wt_root=$(git rev-parse --show-toplevel 2>/dev/null) -[ -n "$main_wt" ] && [ -n "$wt_root" ] && [ "$main_wt" != "$wt_root" ] || exit 0 +[ -n "$wt_root" ] || exit 0 -# Effective include: repo .worktreeinclude if present, else nothing to seed-copy. -include="" -if [ -f "$main_wt/.worktreeinclude" ]; then - include="$main_wt/.worktreeinclude" -fi +if [ -n "$main_wt" ] && [ "$main_wt" != "$wt_root" ]; then + # Effective include: repo .worktreeinclude if present, else nothing to seed-copy. + include="" + if [ -f "$main_wt/.worktreeinclude" ]; then + include="$main_wt/.worktreeinclude" + fi # Probe once: reflink (CoW fs) > hard link (same fs) > plain copy (cross-device). probe="$main_wt/.git-hook-clone-probe.$$" @@ -73,6 +72,7 @@ if [ -n "$include" ] && [ -f "$include" ]; then fi done fi +fi # Shared-store hardlink install: fast when the store is warm, no inode clone storm. [ -f "$wt_root/package.json" ] || exit 0 diff --git a/.husky/pre-commit b/.githooks/pre-commit similarity index 100% rename from .husky/pre-commit rename to .githooks/pre-commit diff --git a/.husky/pre-push b/.githooks/pre-push similarity index 100% rename from .husky/pre-push rename to .githooks/pre-push diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index a5de4117b7c5..c699c694d4cc 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -178,14 +178,29 @@ it.effect("clones a looked-up repository into the requested destination", () => cwd: parent, args: ["clone", CLONE_URLS.url, "t3code"], }, + { + cwd: destinationPath, + args: ["config", "core.hooksPath", ".githooks"], + }, + { + cwd: destinationPath, + args: ["checkout", "--force", "HEAD"], + }, ]); }).pipe( Effect.provide( makeLayer({ git: { execute: (input) => - Effect.sync(() => { + Effect.gen(function* () { cloneCalls.push({ cwd: input.cwd, args: input.args }); + if (input.args[0] === "clone") { + yield* fs.makeDirectory(`${destinationPath}/.githooks`, { recursive: true }); + yield* fs.writeFileString( + `${destinationPath}/.githooks/post-checkout`, + "#!/bin/sh\n", + ); + } return processOutput(); }), }, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..78d416bb7881 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -211,6 +211,29 @@ export const make = Effect.gen(function* () { maxOutputBytes: 256 * 1024, }); + const nativePostCheckout = path.join( + preparedDestination.destinationPath, + ".githooks", + "post-checkout", + ); + if (yield* fileSystem.exists(nativePostCheckout)) { + yield* git.execute({ + operation: "SourceControlRepositoryService.configureNativeHooks", + cwd: preparedDestination.destinationPath, + args: ["config", "core.hooksPath", ".githooks"], + }); + // Clone's checkout happened before the repository-owned hooks were configured. + // Re-checking out the fresh HEAD invokes post-checkout synchronously, so setup + // (including pnpm install) finishes before the clone is handed to the user. + yield* git.execute({ + operation: "SourceControlRepositoryService.prepareClone", + cwd: preparedDestination.destinationPath, + args: ["checkout", "--force", "HEAD"], + timeoutMs: 120_000, + maxOutputBytes: 256 * 1024, + }); + } + return { cwd: preparedDestination.destinationPath, remoteUrl, diff --git a/package.json b/package.json index 385dc1a8d5dd..0e648c1b6541 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", - "husky": "^9.1.7", "lint-staged": "^16.4.0", "vite-plus": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fdf0c898af0..6456567be048 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,6 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 - husky: - specifier: ^9.1.7 - version: 9.1.7 lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -7221,11 +7218,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -17923,8 +17915,6 @@ snapshots: transitivePeerDependencies: - supports-color - husky@9.1.7: {} - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 diff --git a/scripts/hooks/post-checkout.test.sh b/scripts/hooks/post-checkout.test.sh index 7fa897518735..eadeba149ce3 100755 --- a/scripts/hooks/post-checkout.test.sh +++ b/scripts/hooks/post-checkout.test.sh @@ -1,13 +1,12 @@ #!/usr/bin/env bash -# Exercises the canonical worktree post-checkout hook and the shared-hooks-dir -# wiring that scripts/install-git-hooks.mjs sets up: a raw `git worktree add` -# must fire post-checkout (so node_modules gets installed) while commit/push -# hooks still delegate to the worktree's checked-in .husky/. +# Exercises the tracked native hooks configured by install-git-hooks.mjs: a raw +# `git worktree add` must fire post-checkout and commits must use the checked-in +# pre-commit hook from that worktree. set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -hook="${root}/scripts/hooks/post-checkout" +hook="${root}/.githooks/post-checkout" [ -f "${hook}" ] || { echo "missing ${hook}" >&2 exit 1 @@ -18,41 +17,29 @@ git init -q "${work}/main" cd "${work}/main" git config user.email t@example.com git config user.name t -mkdir -p .husky -printf '#!/bin/sh\necho FIRED > "$(git rev-parse --show-toplevel)/.precommit"\n' >.husky/pre-commit -chmod +x .husky/pre-commit +mkdir -p .githooks +install -m 0755 "${hook}" .githooks/post-checkout +printf '#!/bin/sh\necho FIRED > "$(git rev-parse --show-toplevel)/.precommit"\n' >.githooks/pre-commit +chmod +x .githooks/pre-commit git add -A git commit -q -m init -# --- the real post-checkout no-ops in the main worktree (git-dir == common-dir) --- +# A file checkout (third argument 0) must not reconcile dependencies. out="$(sh "${hook}" 2>&1 || true)" [ -z "${out}" ] || { - echo "FAIL: post-checkout should be silent/no-op in the main worktree, got: ${out}" >&2 + echo "FAIL: file checkout should be silent/no-op, got: ${out}" >&2 exit 1 } -# --- wire a shared absolute hooks dir the way install-git-hooks.mjs does --- -common="$(cd "$(git rev-parse --git-common-dir)" && pwd)" -shared="${common}/t3-hooks" -mkdir -p "${shared}" -install -m 0755 "${hook}" "${shared}/post-checkout" -cat >"${shared}/pre-commit" <<'DISPATCH' -#!/bin/sh -name=${0##*/} -top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -h="$top/.husky/$name" -[ -f "$h" ] || exit 0 -exec sh "$h" "$@" -DISPATCH -chmod +x "${shared}/pre-commit" -git config core.hooksPath "${shared}" +# Wire the tracked hook directory the way install-git-hooks.mjs does. +git config core.hooksPath .githooks # --- a fresh `git worktree add` fires post-checkout (no pnpm-lock -> clean skip, exit 0) --- git worktree add -q "${work}/wt" HEAD # post-checkout must not abort the checkout even with nothing to install. test -d "${work}/wt" -# --- commit in the fresh worktree still runs the husky pre-commit via dispatcher --- +# A commit in the fresh worktree runs its tracked native pre-commit hook. cd "${work}/wt" git config user.email t@example.com git config user.name t @@ -60,7 +47,7 @@ echo change >f.txt git add f.txt git commit -q -m c [ -f "${work}/wt/.precommit" ] && grep -q FIRED "${work}/wt/.precommit" || { - echo "FAIL: husky pre-commit did not run through the shared-dir dispatcher" >&2 + echo "FAIL: native pre-commit did not run" >&2 exit 1 } diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs index 218ceb4ffc63..93e1bbedd2f8 100644 --- a/scripts/install-git-hooks.mjs +++ b/scripts/install-git-hooks.mjs @@ -1,10 +1,5 @@ #!/usr/bin/env node -// Installs the husky git hooks (runs from the root `prepare` script). -// Exits silently when husky is not installed — e.g. production installs -// without devDependencies. -// -// Git worktrees often miss `core.hooksPath` after husky; re-apply it so -// agent pre-push actually runs in T3/agent worktrees. +// Installs tracked native Git hooks (runs from the root `prepare` script). // // Always installs `.tools/bin/gh` — agent policy shim that blocks `gh pr ready` // (use `pnpm pr:ready`). Put `$REPO/.tools/bin` first on PATH in agent sessions. @@ -41,24 +36,8 @@ try { console.error(`install-git-hooks: could not install agent gh shim: ${error?.message ?? error}`); } -let installHusky; -try { - installHusky = (await import("husky")).default; -} catch (error) { - if (error?.code !== "ERR_MODULE_NOT_FOUND" || !error.message.includes("package 'husky'")) { - throw error; - } - NodeProcess.exit(0); -} - -const installError = installHusky(); -if (installError) { - console.error(installError); - NodeProcess.exit(1); -} - -for (const name of ["pre-commit", "pre-push"]) { - const p = NodePath.join(root, ".husky", name); +for (const name of ["post-checkout", "pre-commit", "pre-push"]) { + const p = NodePath.join(root, ".githooks", name); if (NodeFS.existsSync(p)) { try { NodeFS.chmodSync(p, 0o755); @@ -68,69 +47,11 @@ for (const name of ["pre-commit", "pre-push"]) { } } -// Point core.hooksPath at a shared ABSOLUTE dir under the git common dir. Husky's -// default is a *relative* `.husky/_`, which does not exist in a freshly -// `git worktree add`-ed worktree -- so git skips every hook there, and a raw -// `git worktree add` never installs node_modules (only the T3 app / t3.json -// path did). A shared absolute dir exists for every worktree, including brand -// new ones, so: -// - post-checkout runs the worktree setup (pnpm install from the warm store), -// - pre-commit/pre-push delegate to the current worktree's checked-in -// .husky/, so husky's lint-staged + ship gate keep working. -// This runs on every `pnpm install`, so smart and the t3vm guest converge on -// identical worktree behavior without a host-specific installer. -const configureSharedHooks = () => { - const gitCommonDir = NodePath.resolve( - root, - NodeChildProcess.execFileSync("git", ["rev-parse", "--git-common-dir"], { - cwd: root, - encoding: "utf8", - }).trim(), - ); - const sharedHooks = NodePath.join(gitCommonDir, "t3-hooks"); - NodeFS.mkdirSync(sharedHooks, { recursive: true }); - - // Canonical worktree-setup hook (shared by both hosts via this installer). - NodeFS.copyFileSync( - NodePath.join(root, "scripts", "hooks", "post-checkout"), - NodePath.join(sharedHooks, "post-checkout"), - ); - NodeFS.chmodSync(NodePath.join(sharedHooks, "post-checkout"), 0o755); - - // One dispatcher per checked-in husky hook. Delegates to the *current - // worktree's* .husky/; no-ops when that worktree has no such hook. - const dispatcher = `#!/bin/sh -# Installed by scripts/install-git-hooks.mjs. core.hooksPath is a shared ABSOLUTE -# dir so post-checkout fires on a fresh 'git worktree add'; this dispatcher runs -# the current worktree's checked-in husky hook so the gate still applies. -name=\${0##*/} -top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0 -hook="$top/.husky/$name" -[ -f "$hook" ] || exit 0 -exec sh "$hook" "$@" -`; - const huskyDir = NodePath.join(root, ".husky"); - const hookNames = NodeFS.existsSync(huskyDir) - ? NodeFS.readdirSync(huskyDir).filter((name) => !name.startsWith("_") && !name.startsWith(".")) - : []; - for (const name of hookNames) { - const target = NodePath.join(sharedHooks, name); - NodeFS.writeFileSync(target, dispatcher, { encoding: "utf8", mode: 0o755 }); - try { - NodeFS.chmodSync(target, 0o755); - } catch { - // best-effort - } - } - - NodeChildProcess.execFileSync("git", ["config", "core.hooksPath", sharedHooks], { cwd: root }); -}; - try { - configureSharedHooks(); + NodeChildProcess.execFileSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: root }); } catch (error) { console.error( - `install-git-hooks: could not configure shared hooks dir: ${error?.message ?? error}`, + `install-git-hooks: could not configure native hooks dir: ${error?.message ?? error}`, ); } From 6c9e3730cea20995f6bafe6a99c119cb047d8884 Mon Sep 17 00:00:00 2001 From: Stack Test Date: Mon, 3 Aug 2026 14:43:30 +0200 Subject: [PATCH 2/3] test: keep clone hook fixture synchronous --- .../SourceControlRepositoryService.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index c699c694d4cc..61e997e85c86 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,3 +1,5 @@ +import * as NodeFS from "node:fs"; + import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -192,14 +194,11 @@ it.effect("clones a looked-up repository into the requested destination", () => makeLayer({ git: { execute: (input) => - Effect.gen(function* () { + Effect.sync(() => { cloneCalls.push({ cwd: input.cwd, args: input.args }); if (input.args[0] === "clone") { - yield* fs.makeDirectory(`${destinationPath}/.githooks`, { recursive: true }); - yield* fs.writeFileString( - `${destinationPath}/.githooks/post-checkout`, - "#!/bin/sh\n", - ); + NodeFS.mkdirSync(`${destinationPath}/.githooks`, { recursive: true }); + NodeFS.writeFileSync(`${destinationPath}/.githooks/post-checkout`, "#!/bin/sh\n"); } return processOutput(); }), From 688c82c594e888adb2855057946d0ccd78ad28e1 Mon Sep 17 00:00:00 2001 From: Stack Test Date: Mon, 3 Aug 2026 14:44:25 +0200 Subject: [PATCH 3/3] test: map clone fixture filesystem failures --- .../SourceControlRepositoryService.test.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 61e997e85c86..24f960cfbcab 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,5 +1,3 @@ -import * as NodeFS from "node:fs"; - import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -194,14 +192,29 @@ it.effect("clones a looked-up repository into the requested destination", () => makeLayer({ git: { execute: (input) => - Effect.sync(() => { + Effect.gen(function* () { cloneCalls.push({ cwd: input.cwd, args: input.args }); if (input.args[0] === "clone") { - NodeFS.mkdirSync(`${destinationPath}/.githooks`, { recursive: true }); - NodeFS.writeFileSync(`${destinationPath}/.githooks/post-checkout`, "#!/bin/sh\n"); + yield* fs.makeDirectory(`${destinationPath}/.githooks`, { recursive: true }); + yield* fs.writeFileString( + `${destinationPath}/.githooks/post-checkout`, + "#!/bin/sh\n", + ); } return processOutput(); - }), + }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + failureKind: "unknown", + detail: "Could not create the clone fixture.", + cause, + }), + ), + ), }, }), ),