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
9 changes: 9 additions & 0 deletions .githooks/README.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 11 additions & 11 deletions scripts/hooks/post-checkout → .githooks/post-checkout
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.$$"
Expand Down Expand Up @@ -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
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,43 @@ 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();
}),
}).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,
}),
),
),
},
}),
),
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/sourceControl/SourceControlRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
},
Expand Down
10 changes: 0 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 14 additions & 27 deletions scripts/hooks/post-checkout.test.sh
Original file line number Diff line number Diff line change
@@ -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/<name>.
# 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
Expand All @@ -18,49 +17,37 @@ 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
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
}

Expand Down
89 changes: 5 additions & 84 deletions scripts/install-git-hooks.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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/<name>, 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/<name>; 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}`,
);
}

Expand Down
Loading