no-yolo-commits 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,13 @@ This stops that.
11
11
  npx no-yolo-commits init
12
12
  ```
13
13
 
14
- One command. Two things happen on every commit from now on:
14
+ One command. On every commit from now on:
15
15
 
16
- 1. **An AI actually reads your diff** (via the [`claude`](https://claude.com/claude-code) CLI) and blocks the commit — but only for real, high-confidence problems. Not vibes, not "you could refactor this." Type-safety holes, bugs that will actually bite, broken framework patterns. If the reviewer is missing, slow, or having a bad day, it fails **open** — a flaky linter should never be the reason your commit is stuck.
17
- 2. **`main` and `master` become look-but-don't-touch.** Try to commit straight there and instead of yelling at you, it just... makes you a branch. `ACME-1788716508-fix-the-thing-you-were-actually-fixing`, ready to go, commit already on it. You didn't even have to think of a branch name — the AI wrote that from your diff too.
16
+ 1. **A secret scan runs first, always.** Private keys, AWS/Slack-shaped tokens, a staged `.env` — caught with plain regex, zero dependency on any AI CLI being installed or working. Blocks by default; a leaked credential isn't the kind of mistake you want an "unavailable reviewer" to quietly let through.
17
+ 2. **An AI actually reads your diff** (via the [`claude`](https://claude.com/claude-code) CLI) and blocks the commit — but only for real, high-confidence problems. Not vibes, not "you could refactor this." If the reviewer is missing, slow, or having a bad day, it fails **open** — a flaky reviewer should never be the reason your commit is stuck. Skipped automatically when everything staged is a lockfile or generated asset, so bumping `package-lock.json` doesn't cost you a 2-minute wait.
18
+ 3. **`main`/`master` become look-but-don't-touch.** Commit straight there and instead of yelling at you, it just makes you a branch — `ACME-1788716508-fix-the-thing-you-were-actually-fixing`, ready to go, commit already on it. It even wrote the branch name from your diff.
18
19
 
19
- No dashboard. No config file to argue with. No dependencies at runtime — it's a ~150-line shell script wearing a `husky` trenchcoat.
20
+ No dashboard. No config file to argue with. No runtime dependencies — it's shell scripts wearing a [husky](https://typicode.github.io/husky/) trenchcoat.
20
21
 
21
22
  ## Install
22
23
 
@@ -24,27 +25,64 @@ No dashboard. No config file to argue with. No dependencies at runtime — it's
24
25
  npx no-yolo-commits init
25
26
  ```
26
27
 
27
- That's the whole install. It will, in order:
28
+ That's the whole install, per project. It will, in order:
28
29
 
29
30
  - add `husky` as a devDependency (if you don't have it)
30
31
  - set `"scripts.prepare": "husky"` in `package.json`
31
32
  - drop `.husky/pre-commit` into your repo
32
33
 
34
+ ### Or install it once, everywhere
35
+
36
+ If you're the "I have thirty repos and I am not doing this thirty times" type:
37
+
38
+ ```bash
39
+ npx no-yolo-commits init --global
40
+ ```
41
+
42
+ This wires up git's own [hook template mechanism](https://git-scm.com/docs/git-init#_template_directory) (`git config --global init.templateDir`) — every `git init` and `git clone` from then on gets the hook automatically, no husky, no per-project npm install. The branch prefix is derived per-repo at commit time (since one script now serves every project you touch): the first 3 letters of `package.json`'s `name` (scope dropped, e.g. `@acme/widgets` → `WID`) if there is one, otherwise the repo's folder name.
43
+
44
+ Already-cloned repos won't retroactively pick it up — re-run `git init` inside one (safe, doesn't touch history or remotes) to copy it in. A project that later runs the regular per-project `init` gets its own `.husky/pre-commit`, which takes precedence over the global hook.
45
+
33
46
  ## Make it yours
34
47
 
35
48
  ```bash
36
49
  npx no-yolo-commits init \
37
50
  --prefix=ACME \
38
51
  --stack="Next.js + TypeScript + Postgres" \
39
- --protect=main,master,release
52
+ --protect=main,master,release \
53
+ --model=haiku
40
54
  ```
41
55
 
42
56
  | Flag | Default | Does what it says |
43
57
  |---|---|---|
44
- | `--prefix` | your `package.json` name, shouted in caps | prefix for the auto-branch name |
58
+ | `--prefix` | your `package.json` name, shouted in caps (or `AUTO`, per-repo, in `--global` mode) | prefix for the auto-branch name |
45
59
  | `--stack` | `TypeScript` | tells the reviewer what it's actually looking at, so findings are relevant instead of generic |
46
60
  | `--protect` | `main,master` | which branches you're not allowed to just casually commit to |
47
- | `--force`, `-f` | off | steamroll an existing `.husky/pre-commit` |
61
+ | `--model` | claude's default | passed through as `--model <name>` to every `claude` call — point it at a cheaper/faster model if you commit a lot |
62
+ | `--global`, `-g` | off | install into git's global hook template instead of this one project |
63
+ | `--force`, `-f` | off | steamroll an existing hook |
64
+
65
+ ## Also enforce it in CI
66
+
67
+ A local hook is a courtesy, not a wall — `--no-verify` exists, and a fresh clone hasn't run `npm install` yet. For the same checks on every pull request regardless of what happened locally:
68
+
69
+ ```yaml
70
+ # .github/workflows/no-yolo-commits.yml
71
+ on: pull_request
72
+ jobs:
73
+ review:
74
+ runs-on: ubuntu-latest
75
+ steps:
76
+ - uses: actions/checkout@v4
77
+ with:
78
+ fetch-depth: 0
79
+ - uses: ataztech910/no-yolo-commits@main
80
+ with:
81
+ anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
82
+ stack: "Next.js + TypeScript + Postgres"
83
+ ```
84
+
85
+ Same secret scan, same AI review, run against the PR's diff against its base branch — annotated inline on the PR, fails the check on real findings.
48
86
 
49
87
  ## The eject button
50
88
 
@@ -58,7 +96,7 @@ No questions asked. No shame either — that's what it's there for.
58
96
 
59
97
  ## Why this exists
60
98
 
61
- Because I kept copy-pasting the same `.husky/pre-commit` into every new project and hand-editing the branch prefix like some kind of caveman. Now it's `npx` and thirty seconds.
99
+ Because I kept copy-pasting the same `.husky/pre-commit` into every new project and hand-editing the branch prefix like some kind of caveman. Now it's `npx` and thirty seconds — and it reviews its own commits, because it would be pretty funny if it didn't.
62
100
 
63
101
  ## License
64
102
 
package/bin/cli.js CHANGED
@@ -3,7 +3,8 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { execSync, spawnSync } = require('child_process');
6
+ const os = require('os');
7
+ const { spawnSync } = require('child_process');
7
8
 
8
9
  const CWD = process.cwd();
9
10
 
@@ -13,14 +14,24 @@ function fail(msg) {
13
14
  }
14
15
 
15
16
  function parseArgs(argv) {
16
- const args = { command: 'init', prefix: null, stack: 'TypeScript', protect: 'main,master', force: false };
17
+ const args = {
18
+ command: 'init',
19
+ prefix: null,
20
+ stack: 'TypeScript',
21
+ protect: 'main,master',
22
+ model: null,
23
+ global: false,
24
+ force: false,
25
+ };
17
26
  const rest = [];
18
27
  for (const a of argv) {
19
28
  if (a === '--force' || a === '-f') args.force = true;
29
+ else if (a === '--global' || a === '-g') args.global = true;
20
30
  else if (a === '--help' || a === '-h') args.command = 'help';
21
31
  else if (a.startsWith('--prefix=')) args.prefix = a.slice('--prefix='.length);
22
32
  else if (a.startsWith('--stack=')) args.stack = a.slice('--stack='.length);
23
33
  else if (a.startsWith('--protect=')) args.protect = a.slice('--protect='.length);
34
+ else if (a.startsWith('--model=')) args.model = a.slice('--model='.length);
24
35
  else rest.push(a);
25
36
  }
26
37
  if (rest[0]) args.command = rest[0];
@@ -31,58 +42,89 @@ function printHelp() {
31
42
  console.log(`no-yolo-commits — an AI pre-commit reviewer + "no direct commits to main" guard
32
43
 
33
44
  Usage:
34
- npx no-yolo-commits init [options]
45
+ npx no-yolo-commits init [options] per-project install (via husky)
46
+ npx no-yolo-commits init --global [options] install once, applies to every
47
+ new \`git init\`/\`git clone\`
35
48
 
36
49
  Options:
37
- --prefix=NAME Branch prefix used when auto-creating a branch off a
38
- protected branch, e.g. --prefix=ACME -> ACME-<ts>-<slug>
39
- (default: derived from package.json "name")
50
+ --prefix=NAME Branch prefix for auto-created branches, e.g.
51
+ --prefix=ACME -> ACME-<ts>-<slug>. In --global mode
52
+ this defaults to AUTO (derived from each repo's
53
+ directory name at commit time, since one global script
54
+ serves many repos); per-project it defaults to your
55
+ package.json "name".
40
56
  --stack=TEXT One-line description of the project handed to the AI
41
57
  reviewer, e.g. --stack="Next.js + TypeScript + Postgres"
42
58
  (default: "TypeScript")
43
59
  --protect=a,b,c Comma-separated branch names that block direct commits
44
60
  (default: "main,master")
45
- --force, -f Overwrite an existing .husky/pre-commit
61
+ --model=NAME Pass --model NAME through to every \`claude\` invocation
62
+ (cost/speed control — e.g. --model=haiku)
63
+ --global, -g Install into git's global hook template
64
+ (~/.git-templates by default, or your existing
65
+ \`git config --global init.templateDir\`) instead of
66
+ this one project. Existing repos need \`git init\`
67
+ re-run inside them to pick it up; a project that later
68
+ runs the per-project \`init\` (husky) takes precedence
69
+ over the global hook.
70
+ --force, -f Overwrite an existing hook
46
71
  --help, -h Show this help
47
72
 
73
+ Every commit also gets a zero-dependency secret scan (private keys, AWS/Slack
74
+ tokens, staged .env files, etc.) that blocks regardless of whether \`claude\`
75
+ is installed.
76
+
48
77
  Requires the \`claude\` CLI on PATH to actually run the AI review — if it's
49
78
  missing, the hook warns and lets the commit through (fails open).`);
50
79
  }
51
80
 
52
- function readPackageJson() {
53
- const pkgPath = path.join(CWD, 'package.json');
54
- if (!fs.existsSync(pkgPath)) {
55
- fail('No package.json in the current directory — run this from your project root.');
56
- }
57
- return { pkgPath, pkg: JSON.parse(fs.readFileSync(pkgPath, 'utf8')) };
81
+ function slugifyPrefix(name) {
82
+ return (
83
+ (name || 'wip')
84
+ .replace(/^@[^/]+\//, '') // drop npm scope
85
+ .toUpperCase()
86
+ .replace(/[^A-Z0-9]+/g, '')
87
+ .slice(0, 12) || 'WIP'
88
+ );
58
89
  }
59
90
 
60
- function ensureGitRepo() {
61
- const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: CWD, stdio: 'pipe' });
62
- if (result.status !== 0) {
63
- fail('Not a git repository — run `git init` first.');
64
- }
65
- }
91
+ function buildHookScript(args, defaultPrefixSource) {
92
+ const prefix = args.prefix ? slugifyPrefix(args.prefix) : args.global ? 'AUTO' : slugifyPrefix(defaultPrefixSource);
93
+ const protectedBranches = args.protect
94
+ .split(',')
95
+ .map((b) => b.trim())
96
+ .filter(Boolean)
97
+ .join(' ');
98
+ const modelArgs = args.model ? `--model ${args.model}` : '';
66
99
 
67
- function slugifyPrefix(name) {
68
- return (name || 'wip')
69
- .replace(/^@[^/]+\//, '') // drop npm scope
70
- .toUpperCase()
71
- .replace(/[^A-Z0-9]+/g, '')
72
- .slice(0, 12) || 'WIP';
100
+ const templatePath = path.join(__dirname, '..', 'templates', 'pre-commit.sh');
101
+ let hookScript = fs.readFileSync(templatePath, 'utf8');
102
+ hookScript = hookScript
103
+ .replace(/__STACK__/g, args.stack.replace(/"/g, '\\"'))
104
+ .replace(/__PREFIX__/g, prefix)
105
+ .replace(/__PROTECTED__/g, protectedBranches)
106
+ .replace(/__MODEL_ARGS__/g, modelArgs);
107
+
108
+ return { hookScript, prefix, protectedBranches };
73
109
  }
74
110
 
75
- function run(cmd, args) {
76
- console.log(` $ ${cmd} ${args.join(' ')}`);
77
- const result = spawnSync(cmd, args, { cwd: CWD, stdio: 'inherit' });
111
+ function run(cmd, cmdArgs) {
112
+ console.log(` $ ${cmd} ${cmdArgs.join(' ')}`);
113
+ const result = spawnSync(cmd, cmdArgs, { cwd: CWD, stdio: 'inherit' });
78
114
  if (result.status !== 0) {
79
- fail(`\`${cmd} ${args.join(' ')}\` failed (exit ${result.status}).`);
115
+ fail(`\`${cmd} ${cmdArgs.join(' ')}\` failed (exit ${result.status}).`);
80
116
  }
81
117
  }
82
118
 
83
- function init(args) {
84
- ensureGitRepo();
85
- const { pkgPath, pkg } = readPackageJson();
119
+ function initProject(args) {
120
+ const gitCheck = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: CWD, stdio: 'pipe' });
121
+ if (gitCheck.status !== 0) fail('Not a git repository — run `git init` first.');
122
+
123
+ const pkgPath = path.join(CWD, 'package.json');
124
+ if (!fs.existsSync(pkgPath)) {
125
+ fail('No package.json in the current directory — run this from your project root (or use --global).');
126
+ }
127
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
86
128
 
87
129
  const huskyDir = path.join(CWD, '.husky');
88
130
  const hookPath = path.join(huskyDir, 'pre-commit');
@@ -91,9 +133,7 @@ function init(args) {
91
133
  fail(`${hookPath} already exists — pass --force to overwrite it.`);
92
134
  }
93
135
 
94
- const hasHusky =
95
- (pkg.devDependencies && pkg.devDependencies.husky) || (pkg.dependencies && pkg.dependencies.husky);
96
-
136
+ const hasHusky = (pkg.devDependencies && pkg.devDependencies.husky) || (pkg.dependencies && pkg.dependencies.husky);
97
137
  if (!hasHusky) {
98
138
  console.log('→ Installing husky (devDependency)...');
99
139
  run('npm', ['install', '--save-dev', 'husky']);
@@ -112,33 +152,63 @@ function init(args) {
112
152
  console.log('→ Wiring up husky (git hooksPath)...');
113
153
  run('npx', ['husky']);
114
154
 
115
- const prefix = slugifyPrefix(args.prefix || freshPkg.name);
116
- const protectedBranches = args.protect
117
- .split(',')
118
- .map((b) => b.trim())
119
- .filter(Boolean)
120
- .join(' ');
121
-
122
- const templatePath = path.join(__dirname, '..', 'templates', 'pre-commit.sh');
123
- let hookScript = fs.readFileSync(templatePath, 'utf8');
124
- hookScript = hookScript
125
- .replace(/__STACK__/g, args.stack.replace(/"/g, '\\"'))
126
- .replace(/__PREFIX__/g, prefix)
127
- .replace(/__PROTECTED__/g, protectedBranches);
155
+ const { hookScript, prefix, protectedBranches } = buildHookScript(args, freshPkg.name);
128
156
 
129
157
  fs.mkdirSync(huskyDir, { recursive: true });
130
158
  fs.writeFileSync(hookPath, hookScript);
131
159
  fs.chmodSync(hookPath, 0o755);
132
160
 
133
161
  console.log(`✓ wrote ${path.relative(CWD, hookPath)}`);
162
+ printSummary(prefix, protectedBranches);
163
+ }
164
+
165
+ function initGlobal(args) {
166
+ const configuredDir = spawnSync('git', ['config', '--global', 'init.templateDir'], { stdio: 'pipe' });
167
+ const templateDir =
168
+ configuredDir.status === 0 && configuredDir.stdout.toString().trim()
169
+ ? configuredDir.stdout.toString().trim()
170
+ : path.join(os.homedir(), '.git-templates');
171
+
172
+ const hooksDir = path.join(templateDir, 'hooks');
173
+ const hookPath = path.join(hooksDir, 'pre-commit');
174
+
175
+ if (fs.existsSync(hookPath) && !args.force) {
176
+ fail(`${hookPath} already exists — pass --force to overwrite it.`);
177
+ }
178
+
179
+ const { hookScript, prefix, protectedBranches } = buildHookScript(args, null);
180
+
181
+ fs.mkdirSync(hooksDir, { recursive: true });
182
+ fs.writeFileSync(hookPath, hookScript);
183
+ fs.chmodSync(hookPath, 0o755);
184
+ console.log(`✓ wrote ${hookPath}`);
185
+
186
+ run('git', ['config', '--global', 'init.templateDir', templateDir]);
187
+
188
+ console.log('');
189
+ console.log(`Every \`git init\` and \`git clone\` from now on picks this up automatically.`);
190
+ console.log(`Already-existing local repos won't retroactively get it — re-run \`git init\``);
191
+ console.log(`inside one (safe, doesn't touch history/remotes) to copy it in, or run`);
192
+ console.log(`\`npx no-yolo-commits init\` there for the per-project (husky) version, which`);
193
+ console.log(`takes precedence over this global hook if both are present.`);
194
+ printSummary(prefix, protectedBranches);
195
+ }
196
+
197
+ function printSummary(prefix, protectedBranches) {
134
198
  console.log('');
135
199
  console.log('Done. New behavior on `git commit`:');
136
- console.log(` - AI review of staged changes (needs \`claude\` on PATH) — blocks only on high-confidence issues`);
200
+ console.log(' - secret scan on staged changes (private keys, tokens, .env files) — always on');
201
+ console.log(' - AI review of staged changes (needs `claude` on PATH) — blocks only on high-confidence issues');
137
202
  console.log(` - direct commits to [${protectedBranches}] auto-branch instead, as ${prefix}-<timestamp>-<slug>`);
138
203
  console.log('');
139
204
  console.log('Bypass for one commit: git commit --no-verify');
140
205
  }
141
206
 
207
+ function init(args) {
208
+ if (args.global) return initGlobal(args);
209
+ return initProject(args);
210
+ }
211
+
142
212
  function main() {
143
213
  const args = parseArgs(process.argv.slice(2));
144
214
  if (args.command === 'help') return printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-yolo-commits",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "AI pre-commit review (via the claude CLI) + a guard that auto-branches you off main/master instead of letting a direct commit land there.",
5
5
  "keywords": [
6
6
  "husky",
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env sh
2
+ # Runs in GitHub Actions on a pull_request event. Reviews the PR's diff
3
+ # against its base branch: same secret scan + AI review the local pre-commit
4
+ # hook does, as a defense-in-depth check that isn't bypassable with
5
+ # `git commit --no-verify` or simply not having the hook installed locally.
6
+
7
+ STACK="${STACK:-TypeScript}"
8
+ MODEL_ARGS=""
9
+ if [ -n "${MODEL:-}" ]; then MODEL_ARGS="--model $MODEL"; fi
10
+
11
+ base_ref="${GITHUB_BASE_REF:-}"
12
+ if [ -z "$base_ref" ]; then
13
+ echo "GITHUB_BASE_REF is empty — this action is meant to run on pull_request events. Skipping."
14
+ exit 0
15
+ fi
16
+
17
+ git fetch --no-tags --depth=1 origin "$base_ref" >/dev/null 2>&1
18
+ merge_base="$(git merge-base "origin/$base_ref" HEAD 2>/dev/null)"
19
+ if [ -z "$merge_base" ]; then
20
+ # Shallow fetch didn't reach a common ancestor — try a full fetch once
21
+ # before giving up and diffing straight against the base branch tip.
22
+ git fetch --no-tags origin "$base_ref" >/dev/null 2>&1
23
+ merge_base="$(git merge-base "origin/$base_ref" HEAD 2>/dev/null)"
24
+ fi
25
+ merge_base="${merge_base:-origin/$base_ref}"
26
+
27
+ echo "Reviewing $merge_base...HEAD"
28
+
29
+ # --- secret scan (same patterns as the local pre-commit hook) ---
30
+ secret_hits="$(git diff "$merge_base"...HEAD -U0 -- . 2>/dev/null | grep -E '^\+[^+]' | grep -E -i \
31
+ -e '-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----' \
32
+ -e 'AKIA[0-9A-Z]{16}' \
33
+ -e 'xox[baprs]-[0-9A-Za-z-]{10,}' \
34
+ -e '(api[_-]?key|secret|access[_-]?token|password|passwd)["'"'"']?[[:space:]]*[:=][[:space:]]*["'"'"'][A-Za-z0-9_/+=-]{16,}["'"'"']' \
35
+ 2>/dev/null || true)"
36
+ env_files="$(git diff "$merge_base"...HEAD --name-only -- . 2>/dev/null | grep -E '(^|/)\.env(\.[^.]+)?$' | grep -v -E '\.(example|sample|template|dist)$' || true)"
37
+
38
+ if [ -n "$secret_hits" ] || [ -n "$env_files" ]; then
39
+ echo "::error::Possible secret(s) found in this PR's diff"
40
+ if [ -n "$env_files" ]; then
41
+ echo "$env_files" | while IFS= read -r f; do [ -n "$f" ] && echo "::error::staged env file: $f"; done
42
+ fi
43
+ if [ -n "$secret_hits" ]; then
44
+ echo "$secret_hits" | cut -c1-200 | while IFS= read -r l; do [ -n "$l" ] && echo "::error::$l"; done
45
+ fi
46
+ exit 1
47
+ fi
48
+
49
+ review_diff="$(git diff "$merge_base"...HEAD -- . ':(exclude)package-lock.json' ':(exclude)yarn.lock' ':(exclude)pnpm-lock.yaml' ':(exclude)composer.lock' ':(exclude)Gemfile.lock' ':(exclude)*.svg' ':(exclude)*.png' ':(exclude)*.jpg' ':(exclude)*.jpeg' ':(exclude)*.gif' ':(exclude)*.webp' ':(exclude)*.ico' 2>/dev/null)"
50
+
51
+ if [ -z "$review_diff" ]; then
52
+ echo "Nothing to review (lockfile/generated-asset-only diff)."
53
+ exit 0
54
+ fi
55
+
56
+ if ! command -v claude >/dev/null 2>&1; then
57
+ echo "::warning::claude CLI not found on PATH — skipping AI review"
58
+ exit 0
59
+ fi
60
+
61
+ review_prompt="You are reviewing a pull request's diff before merge, for a $STACK project. Inspect the diff (run \`git diff ${merge_base}...HEAD\` yourself; read full files with the Read tool when a hunk needs more context) and flag ONLY real, high-confidence problems: type-safety holes, missing error handling at real failure points, obvious bugs, broken framework patterns, dead/unused code introduced by this diff, and any secret/API key/credential that looks like it's being committed.
62
+
63
+ Do NOT flag stylistic preferences. Do NOT flag pre-existing patterns already used elsewhere in this codebase. Do NOT invent issues — if you are not confident something is a real problem, leave it out. Respond with ONLY JSON matching the schema, nothing else."
64
+
65
+ review_tmpfile="$(mktemp)"
66
+
67
+ # Background claude directly (no subshell wrapper) so $! is its real PID and
68
+ # the timeout watcher below can actually kill it — see the local hook's
69
+ # comment on this for the failure mode this avoids.
70
+ claude -p "$review_prompt" \
71
+ --output-format json \
72
+ --json-schema '{"type":"object","properties":{"blocking_issues":{"type":"array","items":{"type":"string"}}},"required":["blocking_issues"]}' \
73
+ --allowedTools "Bash(git diff*)" "Bash(git show*)" "Bash(git log*)" "Read" "Grep" "Glob" \
74
+ --disallowedTools "Write" "Edit" "MultiEdit" "NotebookEdit" \
75
+ --permission-mode bypassPermissions \
76
+ $MODEL_ARGS \
77
+ >"$review_tmpfile" 2>/dev/null &
78
+ review_pid=$!
79
+ (sleep 180; kill "$review_pid" >/dev/null 2>&1; exit 0) >/dev/null 2>&1 &
80
+ watcher_pid=$!
81
+ disown "$watcher_pid" >/dev/null 2>&1 || true
82
+
83
+ wait "$review_pid" >/dev/null 2>&1 || true
84
+ kill "$watcher_pid" >/dev/null 2>&1 || true
85
+ wait "$watcher_pid" >/dev/null 2>&1 || true
86
+
87
+ node -e '
88
+ const fs = require("fs")
89
+ try {
90
+ const data = JSON.parse(fs.readFileSync(process.argv[1], "utf-8"))
91
+ if (data.is_error || !data.structured_output) process.exit(2)
92
+ const issues = data.structured_output.blocking_issues || []
93
+ if (issues.length > 0) {
94
+ for (const i of issues) console.log("::error::" + i)
95
+ process.exit(1)
96
+ }
97
+ process.exit(0)
98
+ } catch (e) {
99
+ process.exit(2)
100
+ }
101
+ ' "$review_tmpfile" && status=0 || status=$?
102
+ rm -f "$review_tmpfile" 2>/dev/null || true
103
+
104
+ if [ "$status" = "1" ]; then
105
+ echo "AI review found blocking issues (see annotations above)."
106
+ exit 1
107
+ elif [ "$status" = "2" ]; then
108
+ echo "::warning::AI review unavailable or timed out — not blocking the PR."
109
+ exit 0
110
+ else
111
+ echo "AI review found no blocking issues."
112
+ exit 0
113
+ fi
@@ -1,13 +1,50 @@
1
1
  #!/usr/bin/env sh
2
2
 
3
+ # --- Secret scan on staged changes ---
4
+ # Cheap, deterministic, no dependency on `claude` being installed or
5
+ # working — runs regardless, so a leaked key doesn't slip through just
6
+ # because the AI reviewer below is unavailable. Blocks by default since
7
+ # a leaked credential is a different class of mistake than a code-quality
8
+ # nit; bypass with `git commit --no-verify` for a confirmed false positive.
9
+ if ! git diff --cached --quiet 2>/dev/null; then
10
+ secret_hits="$(git diff --cached -U0 -- . 2>/dev/null | grep -E '^\+[^+]' | grep -E -i \
11
+ -e '-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----' \
12
+ -e 'AKIA[0-9A-Z]{16}' \
13
+ -e 'xox[baprs]-[0-9A-Za-z-]{10,}' \
14
+ -e '(api[_-]?key|secret|access[_-]?token|password|passwd)["'"'"']?[[:space:]]*[:=][[:space:]]*["'"'"'][A-Za-z0-9_/+=-]{16,}["'"'"']' \
15
+ 2>/dev/null || true)"
16
+
17
+ env_files="$(git diff --cached --name-only -- . 2>/dev/null | grep -E '(^|/)\.env(\.[^.]+)?$' | grep -v -E '\.(example|sample|template|dist)$' || true)"
18
+
19
+ if [ -n "$secret_hits" ] || [ -n "$env_files" ]; then
20
+ echo ""
21
+ echo "⛔ Possible secret(s) in staged changes:"
22
+ if [ -n "$env_files" ]; then
23
+ echo "$env_files" | while IFS= read -r f; do [ -n "$f" ] && echo " - staged env file: $f"; done
24
+ fi
25
+ if [ -n "$secret_hits" ]; then
26
+ echo "$secret_hits" | cut -c1-120 | while IFS= read -r l; do [ -n "$l" ] && echo " - $l"; done
27
+ fi
28
+ echo ""
29
+ echo "If this is a false positive: git commit --no-verify"
30
+ exit 1
31
+ fi
32
+ fi
33
+
3
34
  # --- AI code review on staged changes ---
4
35
  # Fails the commit only on real, high-confidence findings. If Claude is
5
36
  # missing, times out, or errors, this fails OPEN (warns, does not block) —
6
37
  # a broken/unavailable review must never be the only thing stopping commits.
7
- if command -v claude >/dev/null 2>&1 && ! git diff --cached --quiet; then
38
+ #
39
+ # Skipped entirely when every staged file is a lockfile/generated asset —
40
+ # nothing there for a code reviewer to usefully say, and it's the case
41
+ # most likely to be a large, slow diff to hand an LLM for zero benefit.
42
+ review_diff_probe="$(git diff --cached -- . ':(exclude)package-lock.json' ':(exclude)yarn.lock' ':(exclude)pnpm-lock.yaml' ':(exclude)composer.lock' ':(exclude)Gemfile.lock' ':(exclude)*.svg' ':(exclude)*.png' ':(exclude)*.jpg' ':(exclude)*.jpeg' ':(exclude)*.gif' ':(exclude)*.webp' ':(exclude)*.ico' 2>/dev/null)"
43
+
44
+ if command -v claude >/dev/null 2>&1 && [ -n "$review_diff_probe" ]; then
8
45
  echo "→ Running AI code review on staged changes..."
9
46
 
10
- review_prompt="You are reviewing staged git changes before they are committed, for a __STACK__ project. Inspect the staged changes (run \`git diff --cached\` yourself; read full files with the Read tool when a diff hunk needs more surrounding context) and flag ONLY real, high-confidence problems: type-safety holes (any/unsafe casts), missing error handling at real failure points, obvious bugs, broken framework patterns, dead or unused code introduced by this diff.
47
+ review_prompt="You are reviewing staged git changes before they are committed, for a __STACK__ project. Inspect the staged changes (run \`git diff --cached\` yourself; read full files with the Read tool when a diff hunk needs more surrounding context) and flag ONLY real, high-confidence problems: type-safety holes (any/unsafe casts), missing error handling at real failure points, obvious bugs, broken framework patterns, dead or unused code introduced by this diff, and any secret/API key/credential that looks like it's being committed.
11
48
 
12
49
  Do NOT flag stylistic preferences. Do NOT flag pre-existing patterns already used elsewhere in this codebase (check before flagging). Do NOT invent issues — if you are not confident something is a real problem, leave it out. Respond with ONLY JSON matching the schema, nothing else."
13
50
 
@@ -23,7 +60,7 @@ Do NOT flag stylistic preferences. Do NOT flag pre-existing patterns already use
23
60
  --json-schema '{"type":"object","properties":{"blocking_issues":{"type":"array","items":{"type":"string"}}},"required":["blocking_issues"]}' \
24
61
  --allowedTools "Bash(git diff*)" "Bash(git show*)" "Bash(git log*)" "Read" "Grep" "Glob" \
25
62
  --disallowedTools "Write" "Edit" "MultiEdit" "NotebookEdit" \
26
- --permission-mode bypassPermissions \
63
+ --permission-mode bypassPermissions __MODEL_ARGS__ \
27
64
  >"$review_tmpfile" 2>/dev/null &
28
65
  review_pid=$!
29
66
  (sleep 120; kill "$review_pid" >/dev/null 2>&1; exit 0) >/dev/null 2>&1 &
@@ -67,10 +104,19 @@ Do NOT flag stylistic preferences. Do NOT flag pre-existing patterns already use
67
104
  else
68
105
  echo "✓ AI review found no blocking issues."
69
106
  fi
107
+ elif command -v claude >/dev/null 2>&1; then
108
+ echo "→ Skipping AI review (only lockfile/generated-asset changes staged)."
70
109
  fi
71
110
 
72
111
  # --- Block direct commits to protected branches ---
73
- branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
112
+ # symbolic-ref first: on a brand-new repo's very first commit (no HEAD
113
+ # commit exists yet — an "unborn" branch) rev-parse --abbrev-ref HEAD
114
+ # fails and prints the literal string "HEAD" to stdout before erroring,
115
+ # which silently bypassed protection entirely on repo #1's commit #1.
116
+ # symbolic-ref resolves the branch name in that state; it only fails on
117
+ # a genuinely detached HEAD, where falling back to "HEAD" is correct
118
+ # (you can't be "on" a protected branch while detached).
119
+ branch="$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
74
120
 
75
121
  case " __PROTECTED__ " in
76
122
  *" $branch "*) ;;
@@ -95,7 +141,7 @@ if command -v claude >/dev/null 2>&1; then
95
141
 
96
142
  # Same reasoning as the review call above: background claude directly,
97
143
  # no subshell wrapper, so $! is the real PID the timeout can kill.
98
- printf '%s' "$diff" | claude -p "$prompt" >"$tmpfile" 2>/dev/null &
144
+ printf '%s' "$diff" | claude -p "$prompt" __MODEL_ARGS__ >"$tmpfile" 2>/dev/null &
99
145
  claude_pid=$!
100
146
  (sleep 25; kill "$claude_pid" >/dev/null 2>&1; exit 0) >/dev/null 2>&1 &
101
147
  watcher_pid=$!
@@ -121,8 +167,36 @@ if [ -z "$slug" ]; then
121
167
  slug="$(fallback_slug)"
122
168
  fi
123
169
 
170
+ # The configured prefix is the literal word AUTO for global (git-template)
171
+ # installs, which serve many unrelated repos from one script — derive the
172
+ # prefix per-repo at commit time instead of baking one in at install time.
173
+ configured_prefix="__PREFIX__"
174
+ if [ "$configured_prefix" = "AUTO" ]; then
175
+ toplevel="$(git rev-parse --show-toplevel 2>/dev/null)"
176
+ prefix=""
177
+ # Prefer package.json's name (first 3 letters, scope dropped) when there
178
+ # is one — falls back to the repo directory name for non-Node projects,
179
+ # or if package.json has no usable "name".
180
+ if [ -n "$toplevel" ] && [ -f "$toplevel/package.json" ] && command -v node >/dev/null 2>&1; then
181
+ prefix="$(node -e '
182
+ try {
183
+ const pkg = require(process.argv[1]);
184
+ const name = String(pkg.name || "").replace(/^@[^/]+\//, "");
185
+ process.stdout.write(name.slice(0, 3).toUpperCase());
186
+ } catch (e) {}
187
+ ' "$toplevel/package.json" 2>/dev/null)"
188
+ fi
189
+ if [ -z "$prefix" ]; then
190
+ prefix="$(basename "$toplevel" 2>/dev/null \
191
+ | tr '[:lower:]' '[:upper:]' | tr -cs 'A-Z0-9' '-' | sed -e 's/^-*//' -e 's/-*$//' | cut -c1-20)"
192
+ fi
193
+ prefix="${prefix:-WIP}"
194
+ else
195
+ prefix="$configured_prefix"
196
+ fi
197
+
124
198
  timestamp="$(date +%s)"
125
- new_branch="__PREFIX__-${timestamp}-${slug}"
199
+ new_branch="${prefix}-${timestamp}-${slug}"
126
200
  new_branch="$(printf '%s' "$new_branch" | cut -c1-100)"
127
201
 
128
202
  branch_err_file="$(mktemp)"