@hifullmoon/aicommit 1.4.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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. package/templates/.aicommit.policy.json +22 -0
@@ -0,0 +1,53 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ import { startReasoningStream } from './ui.js';
5
+
6
+ // Run one provider task with the shared spinner, streamed-reasoning, and
7
+ // Ctrl+C lifecycle used by both normal and split commit flows. Callers keep
8
+ // ownership of retries and error presentation because those policies differ.
9
+ export async function runModelTask({
10
+ spinnerText,
11
+ reasoning,
12
+ machineOutput = false,
13
+ cancelMessage,
14
+ failureMessage,
15
+ successMessage,
16
+ task,
17
+ }) {
18
+ const spinner = ora({ text: chalk.dim(spinnerText), color: 'cyan' }).start();
19
+ let liveReasoning;
20
+ const stream =
21
+ reasoning?.mode === 'on' && !machineOutput
22
+ ? {
23
+ onReasoningDelta(chunk) {
24
+ if (!liveReasoning) {
25
+ spinner.stop();
26
+ liveReasoning = startReasoningStream(reasoning.maxDisplayChars, chunk);
27
+ return;
28
+ }
29
+ liveReasoning.append(chunk);
30
+ },
31
+ }
32
+ : null;
33
+
34
+ const cancelOnSigint = () => {
35
+ spinner.stop();
36
+ console.log(chalk.dim(`\n ${cancelMessage}\n`));
37
+ process.exit(130); // 128 + SIGINT
38
+ };
39
+ process.on('SIGINT', cancelOnSigint);
40
+
41
+ try {
42
+ const result = await task(stream);
43
+ if (liveReasoning) await liveReasoning.stop();
44
+ spinner.succeed(successMessage(result));
45
+ return result;
46
+ } catch (err) {
47
+ if (liveReasoning) await liveReasoning.stop();
48
+ spinner.fail(chalk.red(failureMessage));
49
+ throw err;
50
+ } finally {
51
+ process.removeListener('SIGINT', cancelOnSigint);
52
+ }
53
+ }
package/src/git.js ADDED
@@ -0,0 +1,458 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+
7
+ // Big repos can produce multi-MB diffs; raise the default 1MB child-process
8
+ // buffer and surface a contextual error if the explicit limit is exceeded.
9
+ const MAX_BUFFER = 64 * 1024 * 1024;
10
+
11
+ // Run a read-only git command and preserve the distinction between "no
12
+ // output" and "git failed". Returning an empty string for both cases makes an
13
+ // invalid index, a permissions error, or a too-large response look like a
14
+ // clean working tree.
15
+ export function readGit(args, cwd, maxBuffer = MAX_BUFFER) {
16
+ try {
17
+ return execFileSync('git', args, {
18
+ encoding: 'utf-8',
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ maxBuffer,
21
+ cwd,
22
+ });
23
+ } catch (err) {
24
+ const detail =
25
+ typeof err.stderr === 'string' ? err.stderr.trim() : err.stderr?.toString('utf-8').trim();
26
+ const suffix = detail ? `: ${detail}` : '';
27
+ throw new Error(`git ${args.join(' ')} failed${suffix}`, { cause: err });
28
+ }
29
+ }
30
+
31
+ // --name-status -z emits status/path fields separated by NUL bytes. Unlike
32
+ // the default line-oriented format, paths are never C-quoted, so Unicode,
33
+ // tabs, newlines, and quotes remain safe to pass back to git as argv values.
34
+ function parseNameStatusZ(output) {
35
+ const fields = output.split('\0');
36
+ if (fields.at(-1) === '') fields.pop();
37
+ const files = [];
38
+ for (let i = 0; i < fields.length;) {
39
+ const status = fields[i++];
40
+ if (!status) continue;
41
+ const first = fields[i++];
42
+ if (first === undefined)
43
+ throw new Error('Unexpected truncated output from git --name-status -z.');
44
+ if (status.startsWith('R') || status.startsWith('C')) {
45
+ const second = fields[i++];
46
+ if (second === undefined)
47
+ throw new Error('Unexpected truncated rename output from git --name-status -z.');
48
+ files.push({ status, path: `${first} → ${second}`, addPaths: [first, second] });
49
+ } else {
50
+ files.push({ status, path: first, addPaths: [first] });
51
+ }
52
+ }
53
+ return files;
54
+ }
55
+
56
+ // git diff context lines around each hunk (--unified=<n>). Fewer context
57
+ // lines = fewer tokens for the model; commit messages rarely need git's
58
+ // default of 3. Falls back to 1 (the configured default) for anything that
59
+ // isn't a non-negative int.
60
+ export function unifiedArg(contextLines) {
61
+ const n = Number.isInteger(contextLines) && contextLines >= 0 ? contextLines : 1;
62
+ return `--unified=${n}`;
63
+ }
64
+
65
+ // Only the staged diff is considered, so the model sees exactly what
66
+ // `git commit` will commit (staging happens up front, interactively, when
67
+ // nothing is staged yet). Returns '' when nothing is staged.
68
+ export function getStagedDiff(cwd, contextLines) {
69
+ return readGit(['diff', unifiedArg(contextLines), '--staged'], cwd).trim();
70
+ }
71
+
72
+ // Hash the complete staged patch (including binary changes and full object
73
+ // ids) so a long-running AI/review step cannot silently commit a different
74
+ // index from the one used to generate the message.
75
+ export function getIndexFingerprint(cwd) {
76
+ const patch = readGit(['diff', '--staged', '--binary', '--full-index', '--no-ext-diff'], cwd);
77
+ return createHash('sha256').update(patch).digest('hex');
78
+ }
79
+
80
+ function hashFileOrMissing(path) {
81
+ if (!existsSync(path)) return '<missing>';
82
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
83
+ }
84
+
85
+ // Save and, unless explicitly released, restore the exact Git index around
86
+ // tool-owned staging. The exit hook also covers Ctrl+C and existing
87
+ // process.exit paths. Restoration is skipped if another process changed the
88
+ // index after our last known mutation, avoiding clobbering concurrent work.
89
+ export function createIndexTransaction(projectRoot) {
90
+ const rawIndexPath = readGit(['rev-parse', '--git-path', 'index'], projectRoot).trim();
91
+ const indexPath = isAbsolute(rawIndexPath) ? rawIndexPath : resolve(projectRoot, rawIndexPath);
92
+ const tempDir = mkdtempSync(join(tmpdir(), 'aicommit-index-'));
93
+ const backupPath = join(tempDir, 'index');
94
+ const existed = existsSync(indexPath);
95
+ if (existed) copyFileSync(indexPath, backupPath);
96
+
97
+ let active = true;
98
+ let ownedHash = hashFileOrMissing(indexPath);
99
+
100
+ const cleanup = () => {
101
+ rmSync(tempDir, { recursive: true, force: true });
102
+ };
103
+ const release = () => {
104
+ if (!active) return;
105
+ active = false;
106
+ process.removeListener('exit', onExit);
107
+ cleanup();
108
+ };
109
+ const restore = ({ force = false } = {}) => {
110
+ if (!active) return true;
111
+ if (!force && hashFileOrMissing(indexPath) !== ownedHash) {
112
+ release();
113
+ return false;
114
+ }
115
+ try {
116
+ if (existed) {
117
+ mkdirSync(dirname(indexPath), { recursive: true });
118
+ copyFileSync(backupPath, indexPath);
119
+ } else {
120
+ rmSync(indexPath, { force: true });
121
+ }
122
+ return true;
123
+ } finally {
124
+ release();
125
+ }
126
+ };
127
+ const onExit = () => {
128
+ try {
129
+ if (!restore()) {
130
+ process.stderr.write(
131
+ '\n ⚠ Git index changed outside aicommit; it was left untouched instead of restoring the snapshot.\n',
132
+ );
133
+ }
134
+ } catch (err) {
135
+ process.stderr.write(`\n ⚠ Failed to restore the Git index snapshot: ${err.message}\n`);
136
+ }
137
+ };
138
+
139
+ process.once('exit', onExit);
140
+ return {
141
+ markOwned() {
142
+ ownedHash = hashFileOrMissing(indexPath);
143
+ },
144
+ restore,
145
+ release,
146
+ };
147
+ }
148
+
149
+ // Compact one-line-per-file summary of the same staged changes getStagedDiff
150
+ // returns. Used to prepend context when a diff is condensed, so the model
151
+ // still sees the full change scope without paying for every hunk.
152
+ export function getDiffStat(cwd) {
153
+ return readGit(['diff', '--stat', '--staged'], cwd).trim();
154
+ }
155
+
156
+ // Lock files only record resolved dependency versions — their content carries
157
+ // no commit intent, and a package-lock.json bump can be tens of thousands of
158
+ // lines. Matching by basename (case-insensitive) covers them at any depth.
159
+ const LOCK_FILE_RE =
160
+ /^(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock|bun\.lockb|gemfile\.lock|go\.sum|cargo\.lock|composer\.lock|poetry\.lock|pipfile\.lock|uv\.lock|deno\.lock|gradle\.lockfile|.*\.terraform\.lock\.hcl)$/i;
161
+
162
+ export function isLockFile(path) {
163
+ const base = (path || '').split('/').pop() || path;
164
+ return LOCK_FILE_RE.test(base);
165
+ }
166
+
167
+ // Match a path against a user-supplied strip pattern ("stripFiles" config).
168
+ // Patterns match the basename case-insensitively; "*" matches any run of
169
+ // characters and "?" a single one (e.g. "*.min.js", "*.map", "*.snap").
170
+ // Generated artifacts like these carry no commit intent but can be huge.
171
+ export function matchStripPattern(path, patterns) {
172
+ const base = (path || '').split('/').pop() || path;
173
+ return (patterns || []).some((glob) => {
174
+ if (typeof glob !== 'string' || !glob) return false;
175
+ const re = new RegExp(
176
+ '^' +
177
+ glob
178
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
179
+ .replace(/\*/g, '.*')
180
+ .replace(/\?/g, '.') +
181
+ '$',
182
+ 'i',
183
+ );
184
+ return re.test(base);
185
+ });
186
+ }
187
+
188
+ // Paths on a "diff --git a/x b/y" header line, unquoted and without the a/ b/
189
+ // prefix. Handles git's C-style quoting of paths that contain spaces.
190
+ function diffSectionPaths(line) {
191
+ const rest = line.replace(/^diff --git\s+/, '');
192
+ const parts = rest.match(/(?:"[^"]*"|\S+)/g) || [];
193
+ return parts.map((p) => p.replace(/^"|"$/g, '').replace(/^[ab]\//, ''));
194
+ }
195
+
196
+ // Replace the body of every lock-file (and "stripFiles"-matched) section in
197
+ // a raw git diff with a short stub, keeping only the "diff --git" header so
198
+ // the model still sees that the file changed. git diff sections begin at
199
+ // lines starting with "diff --git".
200
+ export function stripLockFileContent(diff, stripGlobs = []) {
201
+ const sections = diff.split(/(?=^diff --git )/m);
202
+ const out = [];
203
+ for (const sec of sections) {
204
+ if (!sec.trim()) continue;
205
+ const line = sec.split('\n', 1)[0];
206
+ const paths = diffSectionPaths(line);
207
+ if (paths.some(isLockFile)) {
208
+ out.push(`${line}\n(lock file — content omitted)\n`);
209
+ } else if (paths.some((p) => matchStripPattern(p, stripGlobs))) {
210
+ out.push(`${line}\n(generated file — content omitted)\n`);
211
+ } else {
212
+ out.push(sec);
213
+ }
214
+ }
215
+ return out.join('');
216
+ }
217
+
218
+ // Cap a single file's diff section at maxChars, cutting at a line boundary
219
+ // and marking the cut. One huge section (e.g. a new generated asset) keeps
220
+ // only its header and leading hunks instead of eating the whole prompt
221
+ // budget and pushing every other file out.
222
+ function capSection(sec, maxChars) {
223
+ if (sec.length <= maxChars) return sec;
224
+ const nl = sec.lastIndexOf('\n', maxChars);
225
+ return (
226
+ sec.slice(0, nl > 0 ? nl : maxChars) +
227
+ `\n... (file section truncated — ${sec.length} chars total)\n`
228
+ );
229
+ }
230
+
231
+ // Cap a diff: first truncate any single file section beyond maxSectionChars,
232
+ // then — if the result still exceeds maxChars — keep complete per-file
233
+ // sections until the budget is reached (never cutting mid-hunk) and prepend
234
+ // a --stat summary when provided. `truncated` tells callers whether any cut
235
+ // happened.
236
+ export function condenseDiff(diff, maxChars, stat, maxSectionChars = Infinity) {
237
+ if (Number.isFinite(maxSectionChars) && maxSectionChars > 0) {
238
+ const sections = diff.split(/(?=^diff --git )/m);
239
+ let capped = '';
240
+ let sectionCut = false;
241
+ for (const sec of sections) {
242
+ if (!sec.trim()) continue;
243
+ if (sec.length > maxSectionChars) sectionCut = true;
244
+ capped += capSection(sec, maxSectionChars);
245
+ }
246
+ if (sectionCut) {
247
+ if (capped.length <= maxChars) return { diff: capped, truncated: true };
248
+ diff = capped; // still over budget — run the per-file pass on capped sections
249
+ }
250
+ }
251
+
252
+ if (diff.length <= maxChars) return { diff, truncated: false };
253
+ const sections = diff.split(/(?=^diff --git )/m);
254
+ let kept = '';
255
+ for (const sec of sections) {
256
+ if (!sec.trim()) continue;
257
+ if (kept.length + sec.length > maxChars) break;
258
+ kept += sec;
259
+ }
260
+ const marker = `... (diff truncated — ${diff.length} chars total)`;
261
+ const body = kept ? `${kept}\n${marker}` : marker;
262
+ return { diff: stat ? `${stat}\n\n${body}` : body, truncated: true };
263
+ }
264
+
265
+ export function getChangedFiles(cwd) {
266
+ return parseNameStatusZ(readGit(['diff', '--name-status', '-z', '--staged'], cwd)).map(
267
+ ({ status, path }) => ({ status, path }),
268
+ );
269
+ }
270
+
271
+ // Working-tree changes vs. the index (git diff without --staged) — same shape
272
+ // as getChangedFiles. git diff --staged returns empty for these, so the "no
273
+ // staged changes" path uses this to surface them instead of telling the user
274
+ // there's nothing to commit when git status clearly shows work. `addPaths`
275
+ // carries the real path(s) for `git add` — `path` stays a display string
276
+ // ("old → new" for renames), which git cannot add directly.
277
+ export function getUnstagedFiles(cwd) {
278
+ return parseNameStatusZ(readGit(['diff', '--name-status', '-z'], cwd));
279
+ }
280
+
281
+ export function getDiffStats(diff) {
282
+ if (!diff) return { files: 0, additions: 0, deletions: 0 };
283
+ const lines = diff.split('\n');
284
+ let files = 0,
285
+ additions = 0,
286
+ deletions = 0;
287
+ for (const line of lines) {
288
+ if (line.startsWith('diff --git')) files++;
289
+ else if (line.startsWith('+') && !line.startsWith('+++')) additions++;
290
+ else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
291
+ }
292
+ return { files, additions, deletions };
293
+ }
294
+
295
+ // Untracked files (respecting .gitignore) — they never show up in git diff,
296
+ // so the "no changes" path uses this to point the user at them.
297
+ export function getUntrackedFiles(cwd) {
298
+ const out = readGit(['ls-files', '-z', '--others', '--exclude-standard'], cwd);
299
+ const paths = out.split('\0');
300
+ if (paths.at(-1) === '') paths.pop();
301
+ return paths;
302
+ }
303
+
304
+ export function getBranch(cwd) {
305
+ return readGit(['branch', '--show-current'], cwd).trim();
306
+ }
307
+
308
+ // Keep the explicit repo check so the CLI can give a targeted error before it
309
+ // starts reading diffs.
310
+ export function isGitRepo(cwd) {
311
+ try {
312
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
313
+ cwd,
314
+ stdio: ['pipe', 'pipe', 'ignore'],
315
+ });
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+
322
+ export function gitCommit(message, projectRoot, diagnosticsOnly = false) {
323
+ // Pass the message as an argv item instead of a shell string — quoting it
324
+ // for a shell breaks on Windows, where cmd.exe ignores single quotes.
325
+ // Do not swallow failures: the CLI must return a non-zero exit status when
326
+ // Git or a commit hook rejects the commit.
327
+ execFileSync('git', ['commit', '-m', message], {
328
+ cwd: projectRoot,
329
+ stdio: diagnosticsOnly ? [process.stdin, process.stderr, process.stderr] : 'inherit',
330
+ });
331
+ return true;
332
+ }
333
+
334
+ const SENSITIVE_FILE_RE =
335
+ /(?:^|\/)(?:\.env(?:\..+)?|\.aicommit\.config\.json|\.npmrc|\.pypirc|\.netrc|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|credentials?(?:\.[^/]*)?|service[-_]?account(?:\.[^/]*)?|[^/]+\.(?:pem|p12|pfx|key|keystore))$/i;
336
+ const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/i;
337
+ const AWS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
338
+ const ASSIGNED_SECRET_RE =
339
+ /(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|token)\b\s*[:=]\s*["']?)([^\s,"'}]{8,})/gi;
340
+
341
+ export function isSensitiveFile(path) {
342
+ const normalized = String(path || '').replace(/\\/g, '/');
343
+ if (/\.env\.(?:example|sample|template)$/i.test(normalized)) return false;
344
+ return SENSITIVE_FILE_RE.test(normalized);
345
+ }
346
+
347
+ // Protect standalone text such as an untracked-file preview. Unlike a Git
348
+ // diff, these lines have no +/- prefix, so they need their own scanner before
349
+ // split planning can safely include them in a model request.
350
+ export function protectSensitiveText(text, path = '(unknown file)') {
351
+ const source = String(text || '');
352
+ if (isSensitiveFile(path)) {
353
+ return { text: '', findings: [`sensitive file: ${path}`] };
354
+ }
355
+ if (PRIVATE_KEY_RE.test(source)) {
356
+ return { text: '', findings: [`private-key material in: ${path}`] };
357
+ }
358
+
359
+ let foundAssignedSecret = false;
360
+ let foundCloudKey = false;
361
+ let protectedText = source.replace(AWS_KEY_RE, () => {
362
+ foundCloudKey = true;
363
+ return '[REDACTED_ACCESS_KEY]';
364
+ });
365
+ protectedText = protectedText.replace(ASSIGNED_SECRET_RE, (_match, prefix) => {
366
+ foundAssignedSecret = true;
367
+ return `${prefix}[REDACTED]`;
368
+ });
369
+
370
+ const findings = [];
371
+ if (foundAssignedSecret) findings.push(`credential-like assignment in: ${path}`);
372
+ if (foundCloudKey) findings.push(`cloud access key in: ${path}`);
373
+ return { text: protectedText, findings };
374
+ }
375
+
376
+ // Build a safer model input without changing what Git will commit. Entire
377
+ // sensitive/private-key sections are omitted; common credential assignments
378
+ // and cloud access-key ids in ordinary source diffs are redacted. This is a
379
+ // warning layer, not a substitute for repository secret scanning.
380
+ export function protectSensitiveDiff(diff) {
381
+ const sections = String(diff || '').split(/(?=^diff --git )/m);
382
+ const out = [];
383
+ const findings = [];
384
+
385
+ for (const sec of sections) {
386
+ if (!sec.trim()) continue;
387
+ const line = sec.split('\n', 1)[0];
388
+ const paths = diffSectionPaths(line);
389
+ const sensitivePath = paths.find(isSensitiveFile);
390
+ if (sensitivePath) {
391
+ findings.push(`sensitive file: ${sensitivePath}`);
392
+ out.push(`${line}\n(sensitive file — content omitted)\n`);
393
+ continue;
394
+ }
395
+ if (PRIVATE_KEY_RE.test(sec)) {
396
+ findings.push(`private-key material in: ${paths.at(-1) || '(unknown file)'}`);
397
+ out.push(`${line}\n(private key material — content omitted)\n`);
398
+ continue;
399
+ }
400
+
401
+ let foundAssignedSecret = false;
402
+ let foundCloudKey = false;
403
+ const redacted = sec
404
+ .split('\n')
405
+ .map((diffLine) => {
406
+ // Inspect both added and deleted content: removed credentials are absent
407
+ // from the new snapshot but are still present in the outbound diff.
408
+ const contentLine = diffLine.startsWith('+') || diffLine.startsWith('-');
409
+ if (!contentLine || diffLine.startsWith('+++') || diffLine.startsWith('---'))
410
+ return diffLine;
411
+ let next = diffLine.replace(AWS_KEY_RE, () => {
412
+ foundCloudKey = true;
413
+ return '[REDACTED_ACCESS_KEY]';
414
+ });
415
+ next = next.replace(ASSIGNED_SECRET_RE, (_m, prefix) => {
416
+ foundAssignedSecret = true;
417
+ return `${prefix}[REDACTED]`;
418
+ });
419
+ return next;
420
+ })
421
+ .join('\n');
422
+ const displayPath = paths.at(-1) || '(unknown file)';
423
+ if (foundAssignedSecret) findings.push(`credential-like assignment in: ${displayPath}`);
424
+ if (foundCloudKey) findings.push(`cloud access key in: ${displayPath}`);
425
+ out.push(redacted);
426
+ }
427
+
428
+ return { diff: out.join(''), findings: [...new Set(findings)] };
429
+ }
430
+
431
+ export function runGit(args, projectRoot, inherit = false) {
432
+ try {
433
+ const stdio =
434
+ inherit === 'stderr'
435
+ ? [process.stdin, process.stderr, process.stderr]
436
+ : inherit
437
+ ? 'inherit'
438
+ : 'pipe';
439
+ execFileSync('git', args, { cwd: projectRoot, stdio });
440
+ } catch (err) {
441
+ const detail =
442
+ typeof err.stderr === 'string' ? err.stderr.trim() : err.stderr?.toString('utf-8').trim();
443
+ const suffix = detail ? `: ${detail}` : '';
444
+ throw new Error(`git ${args.join(' ')} failed${suffix}`, { cause: err });
445
+ }
446
+ }
447
+
448
+ export function hasHead(projectRoot) {
449
+ try {
450
+ execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
451
+ cwd: projectRoot,
452
+ stdio: ['pipe', 'pipe', 'ignore'],
453
+ });
454
+ return true;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }