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
8 changes: 6 additions & 2 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
# means `git commit --no-verify` skipped the gate.
__kit_git_dir=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
if [ -n "$__kit_git_dir" ]; then
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/.kit-hook-ran" 2>/dev/null || true
{ date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/.kit-hook-ran"; } 2>/dev/null || true
fi
# pre-commit — block internal references in staged content before they land.
# Term list is resolved outside this repo (see no-internal-leaks.sh header).
"$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --staged || exit 1

kit security scan-staged || exit 1
npm run build || exit 1

# Receipt for kit's post-commit skip-detector (.kit-hook-ran): prove that
# pre-commit actually ran, so honest commits stop logging as "bypassed".
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$(git rev-parse --git-dir)/.kit-hook-ran" 2>/dev/null || true
{ date -u +"%Y-%m-%dT%H:%M:%SZ" > "$(git rev-parse --git-dir)/.kit-hook-ran"; } 2>/dev/null || true
3 changes: 2 additions & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env sh
# pre-push — last gate: scan every tracked file for internal references
# before anything reaches the public remote.
exec "$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --tracked
"$(git rev-parse --show-toplevel)/.githooks/no-internal-leaks.sh" --tracked || exit 1
npm audit --audit-level=high || exit 1
2 changes: 1 addition & 1 deletion .kit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pnpm = "latest"
"aqua:trufflesecurity/trufflehog" = "latest"

[hooks]
pre-commit = ["kit security scan-staged", "npm run build", "npm test"]
pre-commit = ["kit security scan-staged", "npm run build"]
pre-push = ["npm audit --audit-level=high"]

# Scanner tokens (SNYK_TOKEN, …) resolve from the shared sandstream-common
Expand Down
1 change: 1 addition & 0 deletions .kit/shared/memory.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@
{"id":"19e5ae","area":"cli","kind":"decision","title":"kit's own repo does not require a decision ledger","body":"[decisions] require = true is deliberately NOT set in kit's own .kit.toml. The ledger is a per-run artifact produced by an agent-governed run; on a plain CI checkout no agent ran, so requiring one there would gate on the absence of something nothing was asked to produce. require = true belongs on agent-governed runs, not on every checkout. Revisit if kit's CI ever runs an agent that records decisions.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-24T11:36:15.116Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"GVK6jNW1XjLU3q/ESBdScGkB3zNdVdmkI9q1Lac7u+lKiu3hfUsHQPK5SZYLykEk0FagMOgwcO4X4PrXxQ2UDw=="}
{"id":"9c7fa6","area":"cli","kind":"convention","title":"A config section is declared in three places, and the third one warns","body":"Adding a .kit.toml section means kitConfig (type), CONFIG_SECTIONS (config-surface.ts, generates docs/CONFIGURATION.md) AND KNOWN_SECTIONS (config.ts), which loadConfig warns from. The first two were pinned to each other; the third had drifted by two — [supply_chain] and [coverage] are real, honoured sections that printed 'unknown section … (likely a typo)' on every kit invocation. A warning that fires on correct configuration trains the operator to ignore the one that fires on a real typo. config-surface.test.ts now pins all three in both directions.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-24T11:36:15.667Z","source_ref":"5855126","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"0D2CmPQKGkbvtwM5zfM80w3jUa1h1F0AY8Nit6EFMzREVdIbI/PU+ULLVgERFb8G+f2zw46CBt1jSk4qyDAnCw=="}
{"id":"f47c5a","area":"cli","kind":"convention","title":"A gate that exists but is never invoked is the default failure, not the exception","body":"kit adopted its own ADR gate in #403 and no workflow, hook or agent instruction ever called it — armed and unfired for a month, while the rules provably caught violations. A gate nobody runs emits nothing, and nothing reads exactly like a clean run. self-audit-ci already proves every script a workflow points AT exists; the inverse (a gate that exists is pointed at by something) had no rule. When adding a gate to this repo, wire the invocation AND pin it with a test that strips comments and forbids continue-on-error / || true — a gate named in a comment is not a gate, and one that cannot fail the build is a report. General case tracked in #533.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-25T12:06:22.827Z","source_ref":"a49e85c","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"CNhZy+Of23FnzPSF6O95FvxAriwbLWBumewW/9QtgPN606vE4IiidXydMmKQj7JyfBJPOWRBCZM1c7QoSIr4Dw=="}
{"id":"d1814d","area":"cli","kind":"decision","title":"pre-commit excludes full npm test until suite timeouts are fixed","body":"kit-public uses externally managed .githooks. [hooks].pre-commit should require staged security scan + build, not full npm test: a real pre-commit run on 2026-08-27 hit Node test file timeouts in dist/policy-gate.test.js and dist/secrets-propagate.test.js. Re-add full npm test only after those suite timeouts are fixed or the suite is split for hook use.","refs":[],"author":"Peter Sandström <peter@sandstre.am>","ts":"2026-08-27T09:22:36.795Z","source_ref":"3cfa838","kid":"kid_31cd7bffcece85256671f0fced3b42df","sig":"bbdKUzm6w6dIFEacsfFMdkuLhhwBjZ732zvs08X0ZPlv6NQ7J3CgDJ2GpZhf+M8k7Yff8gvlpPej4b+sYHWOBA=="}
14 changes: 13 additions & 1 deletion src/check-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,25 @@ describe("checkHooks", () => {
assert.ok(results[0].detail.includes("not installed"));
});

it("reports not managed by kit when hook lacks the marker", async () => {
it("accepts externally managed hooks when configured commands are present", async () => {
await mkdir(join(gitDir, "hooks"), { recursive: true });
await writeFile(join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\nnpm test\n", "utf-8");

const config: HooksConfig = { "pre-commit": ["npm test"] };
const results = await checkHooks(config);

assert.equal(results[0].installed, true);
assert.equal(results[0].upToDate, true);
assert.ok(results[0].detail.includes("externally managed"));
});

it("reports not managed by kit when external hook misses configured commands", async () => {
await mkdir(join(gitDir, "hooks"), { recursive: true });
await writeFile(join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho manual\n", "utf-8");

const config: HooksConfig = { "pre-commit": ["npm test"] };
const results = await checkHooks(config);

assert.equal(results[0].installed, true);
assert.equal(results[0].upToDate, false);
assert.ok(results[0].detail.includes("not managed by kit"));
Expand Down
17 changes: 10 additions & 7 deletions src/check-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import type { HooksConfig } from "./config.js";
import { missingHookCommands, resolveHooksDir } from "./hooks.js";

export interface HookCheckResult {
hookName: string;
Expand All @@ -22,7 +23,6 @@ export async function checkHooks(
// The SAME resolver the writer uses. Hardcoding `<gitDir>/hooks` here meant that with an
// external `core.hooksPath`, kit installed to one directory and reported on another: the
// declared hooks read as "not installed" while they were installed and firing (#496).
const { resolveHooksDir } = await import("./hooks.js");
const hooksDir = resolveHooksDir(gitDir, cwd);

for (const [hookName, commands] of Object.entries(config)) {
Expand Down Expand Up @@ -61,19 +61,22 @@ async function checkHook(
// Read hook content
const content = await readFile(hookPath, "utf-8");

// Check if it's a kit-generated hook
const missing = missingHookCommands(content, commands);
const upToDate = missing.length === 0;

// A Husky/Lefthook/.githooks-style hook can satisfy the contract too, as
// long as it actually runs the commands declared in [hooks].
if (!content.includes("# Generated by kit")) {
return {
hookName,
installed: true,
upToDate: false,
detail: "not managed by kit",
upToDate,
detail: upToDate
? `externally managed; ${commands.length} configured command(s) present`
: `not managed by kit; missing ${missing.length} configured command(s)`,
};
}

// Check if commands match
const upToDate = commands.every((cmd) => content.includes(cmd));

return {
hookName,
installed: true,
Expand Down
29 changes: 29 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,35 @@ describe("kit fix", () => {
assert.match(result.stdout, /Agenten fortsätter med: kit check --category services,secrets/);
});

it("reports non-kit hooks under core.hooksPath as manual, not fixed", async () => {
await exec("git", ["init", "-q"], { cwd: tempDir });
await exec("git", ["config", "core.hooksPath", ".githooks"], { cwd: tempDir });
await mkdir(join(tempDir, ".githooks"), { recursive: true });
await writeFile(join(tempDir, ".githooks", "pre-commit"), "#!/bin/sh\necho manual\n", "utf-8");
await writeFile(join(tempDir, ".githooks", "pre-push"), "#!/bin/sh\necho manual\n", "utf-8");
await chmod(join(tempDir, ".githooks", "pre-commit"), 0o755);
await chmod(join(tempDir, ".githooks", "pre-push"), 0o755);
await writeFile(
join(tempDir, ".kit.toml"),
'[hooks]\npre-commit = ["kit security scan-staged"]\npre-push = ["npm audit --audit-level=high"]\n',
"utf-8",
);

const result = await runCli(["fix"], tempDir);

assert.equal(result.exitCode, 1, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
assert.match(result.stdout, /pre-commit hook was left untouched/);
assert.match(result.stdout, /pre-push hook was left untouched/);
assert.match(result.stdout, /require human action/);

const preCommit = await readFile(join(tempDir, ".githooks", "pre-commit"), "utf-8");
const prePush = await readFile(join(tempDir, ".githooks", "pre-push"), "utf-8");
assert.doesNotMatch(preCommit, /# Generated by kit/);
assert.doesNotMatch(preCommit, /kit security scan-staged/);
assert.doesNotMatch(prePush, /# Generated by kit/);
assert.doesNotMatch(prePush, /npm audit --audit-level=high/);
});

it("pushes missing deploy env values from declared secrets without echoing values", async () => {
await writeFile(join(tempDir, ".kit.toml"), FIXTURE_DEPLOY_FIX, "utf-8");
const logPath = join(tempDir, "vercel.log");
Expand Down
10 changes: 8 additions & 2 deletions src/commands/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,13 @@ export async function cmdHooks(): Promise<boolean> {
let allOk = true;

for (const r of results) {
const icon = r.action === "failed" ? `${c.red}✗${c.reset}` : `${c.green}✓${c.reset}`;
const ok = r.action !== "failed" && (r.action !== "skipped" || r.satisfied === true);
const icon =
r.action === "failed"
? `${c.red}✗${c.reset}`
: ok
? `${c.green}✓${c.reset}`
: `${c.yellow}!${c.reset}`;
const label =
r.action === "installed"
? `${c.green}installed${c.reset}`
Expand All @@ -92,7 +98,7 @@ export async function cmdHooks(): Promise<boolean> {
? `${c.dim}skipped${c.reset}`
: `${c.red}failed${c.reset}`;
console.log(` ${icon} ${r.hookName} ${label} ${c.dim}${r.detail}${c.reset}`);
if (r.action === "failed") allOk = false;
if (!ok) allOk = false;
}

console.log();
Expand Down
55 changes: 52 additions & 3 deletions src/fix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,27 @@ export async function cmdFix(cwd: string = process.cwd()): Promise<boolean> {
if (config.hooks && Object.keys(config.hooks).length > 0) {
try {
const hookResults = await installHooks(config.hooks, ".git", cwd);
const installed = hookResults.filter((r) => r.action === "installed");
const updated = hookResults.filter((r) => r.action === "updated");
const configuredHookNames = new Set(
Object.entries(config.hooks)
.filter(([, commands]) => commands && commands.length > 0)
.map(([name]) => name),
);
const installed = hookResults.filter(
(r) => r.action === "installed" && configuredHookNames.has(r.hookName),
);
const updated = hookResults.filter(
(r) => r.action === "updated" && configuredHookNames.has(r.hookName),
);
const auxiliary = hookResults.filter(
(r) =>
(r.action === "installed" || r.action === "updated") &&
!configuredHookNames.has(r.hookName),
);
const skipped = hookResults.filter(
(r) => r.action === "skipped" && configuredHookNames.has(r.hookName),
);
const satisfiedSkipped = skipped.filter((r) => r.satisfied);
const blockedSkipped = skipped.filter((r) => !r.satisfied);
const failed = hookResults.filter((r) => r.action === "failed");
if (installed.length > 0) {
console.log(
Expand All @@ -478,6 +497,30 @@ export async function cmdFix(cwd: string = process.cwd()): Promise<boolean> {
` ${c.dim}↻ Updated ${updated.length} existing hook(s): ${updated.map((r) => r.hookName).join(", ")}${c.reset}`,
);
}
if (auxiliary.length > 0) {
console.log(
` ${c.dim}↻ Hook support installed/updated: ${auxiliary.map((r) => r.hookName).join(", ")}${c.reset}`,
);
}
if (satisfiedSkipped.length > 0) {
console.log(
` ${c.green}✓${c.reset} Externally managed hook(s) already satisfy config: ${satisfiedSkipped.map((r) => r.hookName).join(", ")}`,
);
}
if (blockedSkipped.length > 0) {
for (const s of blockedSkipped) {
console.log(` ${c.yellow}!${c.reset} ${s.hookName}: ${s.detail}`);
manualActions.push({
blocker: `${s.hookName} hook was left untouched`,
owner: "developer",
reason: "existing non-kit git hook",
steps: [s.detail, "Run `kit check --category hooks`."],
respondWith: `${s.hookName} hook includes the configured kit commands or is intentionally unmanaged`,
agentContinuesWith: "kit check --category hooks",
});
manualCount++;
}
}
if (failed.length > 0) {
for (const f of failed) {
console.log(` ${c.red}✗${c.reset} ${f.hookName}: ${f.detail}`);
Expand All @@ -492,7 +535,13 @@ export async function cmdFix(cwd: string = process.cwd()): Promise<boolean> {
manualCount++;
}
}
if (installed.length === 0 && updated.length === 0 && failed.length === 0) {
if (
installed.length === 0 &&
updated.length === 0 &&
auxiliary.length === 0 &&
skipped.length === 0 &&
failed.length === 0
) {
console.log(`${c.dim}Hooks up to date${c.reset}`);
}
} catch (err: unknown) {
Expand Down
16 changes: 16 additions & 0 deletions src/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,22 @@ describe("checkHooks", () => {
assert.equal(results[0].upToDate, false);
assert.ok(results[0].detail.includes("not managed by kit"));
});

it("accepts a non-kit hook when it contains the configured commands", async () => {
const config: HooksConfig = {
"pre-commit": ["npm run lint"],
};

await mkdir(join(testGitDir, "hooks"), { recursive: true });
await writeFile(join(testGitDir, "hooks", "pre-commit"), "#!/bin/sh\nnpm run lint\n", "utf-8");

const results = await checkHooks(config, testGitDir);

assert.equal(results.length, 1);
assert.equal(results[0].installed, true);
assert.equal(results[0].upToDate, true);
assert.ok(results[0].detail.includes("externally managed"));
});
});

describe("uninstallHooks", () => {
Expand Down
20 changes: 18 additions & 2 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface HookInstallResult {
hookName: string;
action: "installed" | "updated" | "skipped" | "failed";
detail: string;
/** True when kit did not write the hook, but the existing hook already satisfies the config. */
satisfied?: boolean;
}

export function missingHookCommands(content: string, commands: string[]): string[] {
return commands.filter((cmd) => !content.includes(cmd));
}

/**
Expand Down Expand Up @@ -187,7 +193,7 @@ function sentinelWriterScript(): string {
# means \`git commit --no-verify\` skipped the gate.
__kit_git_dir=\${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
if [ -n "$__kit_git_dir" ]; then
date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/${HOOK_SENTINEL_REL}" 2>/dev/null || true
{ date -u +"%Y-%m-%dT%H:%M:%SZ" > "$__kit_git_dir/${HOOK_SENTINEL_REL}"; } 2>/dev/null || true
fi`;
}

Expand Down Expand Up @@ -263,10 +269,20 @@ async function installHook(
const { readFile } = await import("node:fs/promises");
const current = await readFile(hookPath, "utf-8").catch(() => "");
if (current && !current.includes("Generated by kit")) {
const missing = missingHookCommands(current, commands);
if (missing.length === 0) {
return {
hookName,
action: "skipped",
detail: "externally managed; configured commands already present",
satisfied: true,
};
}
return {
hookName,
action: "skipped",
detail: `existing non-kit ${hookName} at ${hookPath} — left untouched; add \`${commands.join(" && ")}\` to it manually or remove it, then re-run`,
detail: `existing non-kit ${hookName} at ${hookPath} — left untouched; add \`${missing.join(" && ")}\` to it manually or remove it, then re-run`,
satisfied: false,
};
}
}
Expand Down
Loading