Skip to content

Commit 88dce2e

Browse files
PerishCodeclaude
andauthored
feat: add :guard and :init runseal wrappers with git hooks (#3)
:guard runs the local validation suite (cargo fmt/clippy/test --locked, mirroring CI smoke, plus deno fmt/lint/check on .runseal/). :init validates tools/paths and installs pre-commit (refuse main + runseal :guard) and commit-msg (<area>: <summary>) hooks, with --force backup of foreign hooks. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c93d57 commit 88dce2e

7 files changed

Lines changed: 253 additions & 0 deletions

File tree

‎.runseal/hooks/commit-msg‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env sh
2+
# santi :init hook: generated by runseal :init
3+
set -eu
4+
5+
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
6+
echo "commit message file not found" >&2
7+
exit 1
8+
fi
9+
10+
subject=$(sed -n '
11+
/^[[:space:]]*#/d
12+
/[^[:space:]]/ {
13+
s/^[[:space:]]*//
14+
s/[[:space:]]*$//
15+
p
16+
q
17+
}
18+
' "$1")
19+
20+
if [ -z "$subject" ]; then
21+
echo "commit subject is empty" >&2
22+
exit 1
23+
fi
24+
25+
case "$subject" in
26+
"Merge "*|"Revert "*|"fixup!"*|"squash!"*)
27+
exit 0
28+
;;
29+
esac
30+
31+
if ! printf '%s\n' "$subject" | grep -Eq '^[a-z][a-z0-9-]*(/[a-z0-9-]+)*: [^[:space:]].*'; then
32+
echo "commit subject must look like '<area>: <imperative summary>'" >&2
33+
echo "actual: $subject" >&2
34+
exit 1
35+
fi
36+
37+
if [ "${#subject}" -gt 72 ]; then
38+
echo "warning: commit subject is ${#subject} chars; prefer <= 72" >&2
39+
fi

‎.runseal/hooks/pre-commit‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env sh
2+
# santi :init hook: generated by runseal :init
3+
set -eu
4+
root=$(git rev-parse --show-toplevel)
5+
cd "$root"
6+
7+
branch=$(git symbolic-ref --quiet --short HEAD || true)
8+
if [ "$branch" = "main" ]; then
9+
printf '%s\n' "pre-commit: refusing to commit directly on main" >&2
10+
printf '%s\n' "create a feature branch first, then commit again" >&2
11+
exit 1
12+
fi
13+
14+
runseal :guard

‎.runseal/lib/guard/guard.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! `runseal :guard` — the local validation suite the pre-commit hook runs.
2+
//!
3+
//! Mirrors the CI `smoke (ubuntu-latest)` job (cargo fmt/clippy/test, all
4+
//! `--locked` like CI) so "green locally" means "green in CI", plus the Deno
5+
//! checks for the `.runseal/` wrappers that CI does not cover. Runnable on
6+
//! demand as well as from the hook.
7+
8+
import { run } from "@/lib/std/cmd.ts";
9+
import { join } from "@/lib/std/fs.ts";
10+
import { repoRoot } from "@/lib/std/repo.ts";
11+
12+
interface Step {
13+
title: string;
14+
command: string;
15+
args: string[];
16+
}
17+
18+
export async function guard(): Promise<number> {
19+
const repo = repoRoot();
20+
const wrappers = wrapperFiles(repo);
21+
const config = ".runseal/deno.json";
22+
23+
const steps: Step[] = [
24+
{ title: "cargo fmt", command: "cargo", args: ["fmt", "--all", "--check"] },
25+
{
26+
title: "cargo clippy",
27+
command: "cargo",
28+
args: ["clippy", "--locked", "--workspace", "--all-targets", "--", "-D", "warnings"],
29+
},
30+
{ title: "cargo test", command: "cargo", args: ["test", "--locked", "--workspace"] },
31+
{
32+
title: "deno fmt",
33+
command: "deno",
34+
args: ["fmt", "--config", config, "--check", ".runseal"],
35+
},
36+
{ title: "deno lint", command: "deno", args: ["lint", "--config", config, ".runseal"] },
37+
{ title: "deno check", command: "deno", args: ["check", "--config", config, ...wrappers] },
38+
];
39+
40+
for (const step of steps) {
41+
console.log(`==> ${step.title}`);
42+
const code = await run(step.command, step.args, { cwd: repo });
43+
if (code !== 0) {
44+
console.error(`:guard: ${step.title} failed`);
45+
return code;
46+
}
47+
}
48+
console.log("guard: ok");
49+
return 0;
50+
}
51+
52+
/** Discover wrapper entrypoints so `deno check` covers them (and their libs). */
53+
function wrapperFiles(repo: string): string[] {
54+
const files: string[] = [];
55+
for (const entry of Deno.readDirSync(join(repo, ".runseal/wrappers"))) {
56+
if (entry.isFile && entry.name.endsWith(".ts")) {
57+
files.push(`.runseal/wrappers/${entry.name}`);
58+
}
59+
}
60+
return files.sort();
61+
}

‎.runseal/lib/init/init.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//! `runseal :init [--force]` — validate the repo and install git hooks.
2+
//!
3+
//! Installs `.runseal/hooks/{pre-commit,commit-msg}` into the repo's hooks
4+
//! directory. Existing hooks generated by a prior `:init` are replaced; foreign
5+
//! hooks are left alone unless `--force` is given (then backed up first).
6+
7+
import { capture } from "@/lib/std/cmd.ts";
8+
import { exists, join } from "@/lib/std/fs.ts";
9+
10+
const MARKER = "santi :init hook";
11+
const HOOKS = ["pre-commit", "commit-msg"];
12+
const REQUIRED_TOOLS = ["git", "deno", "cargo", "runseal"];
13+
const REQUIRED_PATHS = [
14+
"runseal.toml",
15+
".runseal/deno.json",
16+
".runseal/hooks/pre-commit",
17+
".runseal/hooks/commit-msg",
18+
".runseal/wrappers/guard.ts",
19+
];
20+
21+
export async function init(argv: string[]): Promise<number> {
22+
if (argv.includes("-h") || argv.includes("--help")) {
23+
usage();
24+
return 0;
25+
}
26+
const force = argv.includes("--force");
27+
const positional = argv.find((arg) => !arg.startsWith("-"));
28+
if (positional !== undefined) return fail(`unexpected argument: ${positional}`);
29+
30+
console.log("==> resolving repository");
31+
const root = (await capture("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
32+
if (root === "") return fail("not inside a git repository");
33+
const gitDir = (await capture("git", ["rev-parse", "--absolute-git-dir"])).stdout.trim();
34+
const hooksDir = join(gitDir, "hooks");
35+
console.log(`repository: ${root}`);
36+
37+
console.log("==> checking required tools");
38+
for (const tool of REQUIRED_TOOLS) {
39+
if (!(await toolExists(tool))) return fail(`missing required tool: ${tool}`);
40+
}
41+
console.log(`ok: ${REQUIRED_TOOLS.join(", ")}`);
42+
43+
console.log("==> checking repository entrypoints");
44+
for (const rel of REQUIRED_PATHS) {
45+
if (!exists(join(root, rel))) return fail(`missing required path: ${rel}`);
46+
}
47+
console.log("ok: repository entrypoints");
48+
49+
console.log("==> installing git hooks");
50+
Deno.mkdirSync(hooksDir, { recursive: true });
51+
for (const name of HOOKS) {
52+
const dest = join(hooksDir, name);
53+
const prepared = prepareHook(dest, force);
54+
if (prepared !== 0) return prepared;
55+
Deno.copyFileSync(join(root, ".runseal/hooks", name), dest);
56+
chmod755(dest);
57+
console.log(`installed ${dest}`);
58+
}
59+
console.log("hooks installed — commits now refuse main and run `runseal :guard`");
60+
return 0;
61+
}
62+
63+
function prepareHook(dest: string, force: boolean): number {
64+
if (!exists(dest)) return 0;
65+
let content = "";
66+
try {
67+
content = Deno.readTextFileSync(dest);
68+
} catch {
69+
content = "";
70+
}
71+
if (content.includes(MARKER)) return 0; // generated by us; safe to replace
72+
if (!force) {
73+
return fail(
74+
`${dest} exists and was not generated by :init — rerun with --force to back it up and replace it`,
75+
);
76+
}
77+
const backup = backupNumbered(dest);
78+
console.log(`backed up existing hook to ${backup}`);
79+
return 0;
80+
}
81+
82+
function backupNumbered(path: string): string {
83+
let candidate = `${path}.bak`;
84+
let n = 1;
85+
while (exists(candidate)) candidate = `${path}.bak.${n++}`;
86+
Deno.renameSync(path, candidate);
87+
return candidate;
88+
}
89+
90+
async function toolExists(tool: string): Promise<boolean> {
91+
const result = await capture("sh", ["-c", `command -v ${tool}`]);
92+
return result.code === 0 && result.stdout.trim() !== "";
93+
}
94+
95+
function chmod755(path: string): void {
96+
try {
97+
Deno.chmodSync(path, 0o755);
98+
} catch {
99+
// non-unix platform; git uses its own executable handling
100+
}
101+
}
102+
103+
function usage(): void {
104+
console.log("Usage: runseal :init [--force]");
105+
console.log("");
106+
console.log("Validate the repo and install git hooks (pre-commit, commit-msg).");
107+
console.log(" --force back up a non-generated existing hook before replacing it");
108+
}
109+
110+
function fail(message: string): number {
111+
console.error(`:init: ${message}`);
112+
return 1;
113+
}

‎.runseal/lib/std/fs.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! Tiny filesystem path helpers, shared by wrappers.
2+
3+
export function exists(path: string): boolean {
4+
try {
5+
Deno.statSync(path);
6+
return true;
7+
} catch {
8+
return false;
9+
}
10+
}
11+
12+
export function join(...parts: string[]): string {
13+
return parts.join("/").replace(/(?<!:)\/{2,}/g, "/");
14+
}

‎.runseal/wrappers/guard.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//! `runseal :guard` — run the local validation suite (mirrors CI smoke + Deno
2+
//! checks). Thin entry point; logic lives in the guard module.
3+
4+
import { guard } from "@/lib/guard/guard.ts";
5+
6+
Deno.exit(await guard());

‎.runseal/wrappers/init.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//! `runseal :init [--force]` — install local git hooks. Thin entry point; logic
2+
//! lives in the init module.
3+
4+
import { init } from "@/lib/init/init.ts";
5+
6+
Deno.exit(await init(Deno.args));

0 commit comments

Comments
 (0)