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 +47 -9
- package/bin/cli.js +119 -49
- package/package.json +1 -1
- package/templates/ci-review.sh +113 -0
- package/templates/pre-commit.sh +80 -6
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.
|
|
14
|
+
One command. On every commit from now on:
|
|
15
15
|
|
|
16
|
-
1. **
|
|
17
|
-
2.
|
|
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
|
|
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
|
-
| `--
|
|
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
|
|
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 = {
|
|
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
|
|
38
|
-
|
|
39
|
-
(
|
|
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
|
-
--
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
.replace(/
|
|
72
|
-
.
|
|
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,
|
|
76
|
-
console.log(` $ ${cmd} ${
|
|
77
|
-
const result = spawnSync(cmd,
|
|
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} ${
|
|
115
|
+
fail(`\`${cmd} ${cmdArgs.join(' ')}\` failed (exit ${result.status}).`);
|
|
80
116
|
}
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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(
|
|
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
|
@@ -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
|
package/templates/pre-commit.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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="
|
|
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)"
|