git-coco 0.30.0 → 0.31.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/dist/index.d.ts CHANGED
@@ -177,6 +177,18 @@ type BaseConfig = {
177
177
  * @default true
178
178
  */
179
179
  includeBranchName?: boolean;
180
+ /**
181
+ * The AI CLI tool to use for auto-fixing review issues.
182
+ * Must match a registered adapter key (e.g. "codex", "claude", "gemini").
183
+ * When unset, the auto-fix action is disabled.
184
+ */
185
+ autoFixTool?: string;
186
+ /**
187
+ * Additional key-value flags passed to the auto-fix CLI tool.
188
+ * Keys are flag names (without leading dashes); values are flag values.
189
+ * @example { "model": "o4-mini", "approval-mode": "auto-edit" }
190
+ */
191
+ autoFixToolOptions?: Record<string, string>;
180
192
  };
181
193
  type ConfigWithServiceObject = BaseConfig & Partial<BaseCommandOptions> & {
182
194
  service: LLMService;
@@ -37,7 +37,7 @@ import '@langchain/core/utils/env';
37
37
  import '@langchain/core/utils/async_caller';
38
38
  import { minimatch } from 'minimatch';
39
39
  import { encoding_for_model } from 'tiktoken';
40
- import { exec } from 'child_process';
40
+ import { exec, spawn } from 'child_process';
41
41
  import * as readline from 'readline';
42
42
  import { pathToFileURL } from 'url';
43
43
 
@@ -46,7 +46,7 @@ import { pathToFileURL } from 'url';
46
46
  /**
47
47
  * Current build version from package.json
48
48
  */
49
- const BUILD_VERSION = "0.30.0";
49
+ const BUILD_VERSION = "0.31.0";
50
50
 
51
51
  const isInteractive = (config) => {
52
52
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -928,6 +928,23 @@ const schema$1 = {
928
928
  "type": "boolean",
929
929
  "description": "Whether to include the current branch name in the commit prompt for context. When enabled, the current git branch name will be included in the prompt.",
930
930
  "default": true
931
+ },
932
+ "autoFixTool": {
933
+ "type": "string",
934
+ "description": "The AI CLI tool to use for auto-fixing review issues. Must match a registered adapter key (e.g. \"codex\", \"claude\", \"gemini\"). When unset, the auto-fix action is disabled."
935
+ },
936
+ "autoFixToolOptions": {
937
+ "type": "object",
938
+ "additionalProperties": {
939
+ "type": "string"
940
+ },
941
+ "description": "Additional key-value flags passed to the auto-fix CLI tool. Keys are flag names (without leading dashes); values are flag values.",
942
+ "examples": [
943
+ {
944
+ "model": "o4-mini",
945
+ "approval-mode": "auto-edit"
946
+ }
947
+ ]
931
948
  }
932
949
  },
933
950
  "required": [
@@ -5353,10 +5370,10 @@ class $ZodRegistry {
5353
5370
  }
5354
5371
  }
5355
5372
  // registries
5356
- function registry() {
5373
+ function registry$1() {
5357
5374
  return new $ZodRegistry();
5358
5375
  }
5359
- (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry());
5376
+ (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry$1());
5360
5377
  const globalRegistry = globalThis.__zod_globalRegistry;
5361
5378
 
5362
5379
  // @__NO_SIDE_EFFECTS__
@@ -7179,6 +7196,11 @@ function refine(fn, _params = {}) {
7179
7196
  function superRefine(fn) {
7180
7197
  return _superRefine(fn);
7181
7198
  }
7199
+ // preprocess
7200
+ // /** @deprecated Use `z.pipe()` and `z.transform()` instead. */
7201
+ function preprocess(fn, schema) {
7202
+ return pipe(transform(fn), schema);
7203
+ }
7182
7204
 
7183
7205
  const ChangelogResponseSchema = object({
7184
7206
  title: string(),
@@ -8210,7 +8232,20 @@ function getStatus(file, location = 'index') {
8210
8232
  return 'unknown';
8211
8233
  }
8212
8234
  }
8235
+ else if ('binary' in file && file.binary === true) {
8236
+ // DiffResultBinaryFile: has before/after, no changes/insertions/deletions
8237
+ if (file.file.includes('=>'))
8238
+ return 'renamed';
8239
+ if (file.before === 0 && file.after > 0)
8240
+ return 'added';
8241
+ if (file.after === 0 && file.before > 0)
8242
+ return 'deleted';
8243
+ if (file.before > 0 && file.after > 0)
8244
+ return 'modified';
8245
+ return 'untracked';
8246
+ }
8213
8247
  else if ('changes' in file && 'binary' in file) {
8248
+ // DiffResultTextFile: has changes/insertions/deletions
8214
8249
  if (file.changes === 0)
8215
8250
  return 'untracked';
8216
8251
  if (file.file.includes('=>'))
@@ -12125,15 +12160,47 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
12125
12160
  return summary;
12126
12161
  }
12127
12162
 
12163
+ /**
12164
+ * Detects whether a GitError was caused by a pre-commit hook that modified files.
12165
+ * These hooks (e.g. black, prettier) reformat files and exit non-zero on the first run,
12166
+ * but the commit can succeed once the modified files are re-staged.
12167
+ */
12168
+ function isPreCommitHookModifiedFilesError(error) {
12169
+ if (!(error instanceof Error))
12170
+ return false;
12171
+ const msg = error.message;
12172
+ // pre-commit framework outputs "files were modified by this hook"
12173
+ // git itself outputs "modified files exist in working tree" in some hook setups
12174
+ return (msg.includes('files were modified by this hook') ||
12175
+ msg.includes('modified by this hook') ||
12176
+ msg.includes('hook id:'));
12177
+ }
12128
12178
  /**
12129
12179
  * Creates a commit with the specified commit message.
12180
+ * Handles the case where pre-commit hooks modify files (e.g. black, prettier):
12181
+ * when detected, stages the hook-modified files and retries the commit once.
12130
12182
  *
12131
12183
  * @param message The commit message.
12132
12184
  * @param git The SimpleGit instance.
12185
+ * @param onHookModifiedFiles Optional callback invoked before the auto-retry so callers can notify the user.
12133
12186
  * @returns A Promise that resolves to the CommitResult.
12134
12187
  */
12135
- async function createCommit(message, git) {
12136
- return await git.commit(message);
12188
+ async function createCommit(message, git, onHookModifiedFiles) {
12189
+ try {
12190
+ return await git.commit(message);
12191
+ }
12192
+ catch (error) {
12193
+ if (isPreCommitHookModifiedFilesError(error)) {
12194
+ // Notify caller (e.g. to show a spinner message or log)
12195
+ if (onHookModifiedFiles) {
12196
+ await onHookModifiedFiles();
12197
+ }
12198
+ // Stage all hook-modified files and retry the commit once
12199
+ await git.add('.');
12200
+ return await git.commit(message);
12201
+ }
12202
+ throw error;
12203
+ }
12137
12204
  }
12138
12205
 
12139
12206
  /**
@@ -12712,7 +12779,9 @@ IMPORTANT RULES:
12712
12779
  handleResult({
12713
12780
  result: commitMsg,
12714
12781
  interactiveModeCallback: async (result) => {
12715
- await createCommit(result, git);
12782
+ await createCommit(result, git, () => {
12783
+ logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
12784
+ });
12716
12785
  logSuccess();
12717
12786
  },
12718
12787
  mode: MODE,
@@ -13563,15 +13632,103 @@ const builder = (yargs) => {
13563
13632
  return yargs.options(options).usage(getCommandUsageHeader(command));
13564
13633
  };
13565
13634
 
13635
+ async function buildPrompt(item) {
13636
+ const ext = path.extname(item.filePath).slice(1);
13637
+ let fileSection;
13638
+ try {
13639
+ const contents = await fs.promises.readFile(item.filePath, 'utf-8');
13640
+ fileSection = `\`\`\`${ext}\n${contents}\n\`\`\``;
13641
+ }
13642
+ catch {
13643
+ fileSection = `[WARNING: File "${item.filePath}" was not found on disk. Fix based on the issue description alone.]`;
13644
+ }
13645
+ return `You are an expert software engineer. Fix the following code review issue.
13646
+
13647
+ ## Issue
13648
+ Title: ${item.title}
13649
+ Category: ${item.category}
13650
+ Severity: ${item.severity}/10
13651
+ File: ${item.filePath}
13652
+
13653
+ ## Problem
13654
+ ${item.summary}
13655
+
13656
+ ## File Contents
13657
+ ${fileSection}
13658
+
13659
+ Fix the issue described above. Make only the changes necessary to resolve this specific problem.`;
13660
+ }
13661
+
13662
+ class CodexAdapter {
13663
+ async run(prompt, options, apiKey) {
13664
+ const args = ['exec'];
13665
+ if (options) {
13666
+ for (const [key, value] of Object.entries(options)) {
13667
+ if (key === 'model' || key === 'm') {
13668
+ args.push('--model', value);
13669
+ }
13670
+ else if (key === 'sandbox' || key === 's') {
13671
+ args.push('--sandbox', value);
13672
+ }
13673
+ else {
13674
+ args.push('-c', `${key}=${value}`);
13675
+ }
13676
+ }
13677
+ }
13678
+ args.push('--full-auto', prompt);
13679
+ // Preserve the caller's environment by default and only override the API key
13680
+ // when an explicit non-empty key is provided through auto-fix config.
13681
+ const env = { ...process.env };
13682
+ if (apiKey)
13683
+ env['OPENAI_API_KEY'] = apiKey;
13684
+ return new Promise((resolve, reject) => {
13685
+ const child = spawn('codex', args, { stdio: 'inherit', env });
13686
+ child.on('error', (err) => {
13687
+ if (err.code === 'ENOENT') {
13688
+ reject(new Error('codex binary not found. Please install it: npm i -g @openai/codex'));
13689
+ }
13690
+ else {
13691
+ reject(err);
13692
+ }
13693
+ });
13694
+ child.on('close', (code) => {
13695
+ if (code === 0) {
13696
+ resolve();
13697
+ }
13698
+ else {
13699
+ reject(new Error(`codex exited with code ${code}`));
13700
+ }
13701
+ });
13702
+ });
13703
+ }
13704
+ }
13705
+
13706
+ const registry = {
13707
+ codex: new CodexAdapter(),
13708
+ };
13709
+ async function runAutoFix(item, config) {
13710
+ if (!config.autoFixTool) {
13711
+ return;
13712
+ }
13713
+ const adapter = registry[config.autoFixTool];
13714
+ if (!adapter) {
13715
+ throw new Error(`Unknown autoFixTool: "${config.autoFixTool}"`);
13716
+ }
13717
+ const prompt = await buildPrompt(item);
13718
+ await adapter.run(prompt, config.autoFixToolOptions, config.apiKey);
13719
+ }
13720
+
13566
13721
  class TaskList {
13567
- constructor(items) {
13722
+ constructor(items, config) {
13568
13723
  this.currentIndex = 0;
13569
13724
  this.items = items.map((item) => ({ ...item, status: 'pending' }));
13725
+ this.config = config;
13570
13726
  this.rl = readline.createInterface({
13571
13727
  input: process.stdin,
13572
13728
  output: process.stdout,
13573
13729
  });
13574
13730
  process.stdin.setRawMode(true);
13731
+ readline.emitKeypressEvents(process.stdin);
13575
13732
  }
13576
13733
  async displayCurrentItem() {
13577
13734
  const item = this.items[this.currentIndex];
@@ -13588,18 +13745,12 @@ class TaskList {
13588
13745
  return [
13589
13746
  { name: `✅ Mark as complete ${hotKey('d')}`, value: 'complete' },
13590
13747
  { name: `📂 Open file ${hotKey('o')}`, value: 'open' },
13748
+ { name: `🤖 Auto-fix ${hotKey('a')}`, value: 'autofix' },
13591
13749
  { name: `⏩ Skip ${hotKey('s')}`, value: 'skip' },
13592
13750
  { name: `🙈 Omit ${hotKey('x')}`, value: 'omit' },
13593
13751
  { name: `${exitText} ${hotKey('q')}`, value: 'exit' },
13594
13752
  ];
13595
13753
  }
13596
- async promptAction() {
13597
- const action = await select({
13598
- message: 'Choose an action:',
13599
- choices: this.getChoices(),
13600
- });
13601
- return action;
13602
- }
13603
13754
  async openFile() {
13604
13755
  const item = this.items[this.currentIndex];
13605
13756
  await execPromise(`${process.env.EDITOR || 'code'} ${item.filePath}`);
@@ -13608,6 +13759,35 @@ class TaskList {
13608
13759
  this.items[this.currentIndex].status = 'completed';
13609
13760
  this.navigate(1);
13610
13761
  }
13762
+ async autoFix() {
13763
+ if (!this.config?.autoFixTool) {
13764
+ console.log(chalk.yellow('No autoFixTool configured. Set "autoFixTool" in .coco.config.json'));
13765
+ return;
13766
+ }
13767
+ const item = this.items[this.currentIndex];
13768
+ console.clear();
13769
+ console.log(chalk.bold.cyan(`🤖 Running auto-fix: ${item.title}`));
13770
+ console.log(chalk.dim(`File: ${item.filePath}\n`));
13771
+ // Fully release terminal control before handing off to child process
13772
+ process.stdin.setRawMode(false);
13773
+ process.stdin.pause();
13774
+ await new Promise((r) => setTimeout(r, 50));
13775
+ try {
13776
+ await runAutoFix(item, this.config);
13777
+ this.markAsComplete();
13778
+ console.log(chalk.green('\n✅ Auto-fix completed successfully.'));
13779
+ await new Promise((r) => setTimeout(r, 1200));
13780
+ }
13781
+ catch (err) {
13782
+ console.log(chalk.red(`\n❌ Auto-fix failed: ${err.message}`));
13783
+ await new Promise((r) => setTimeout(r, 1800));
13784
+ }
13785
+ finally {
13786
+ // Restore terminal for our keypress handler
13787
+ process.stdin.resume();
13788
+ process.stdin.setRawMode(true);
13789
+ }
13790
+ }
13611
13791
  skip() {
13612
13792
  this.items[this.currentIndex].status = 'skipped';
13613
13793
  this.navigate(1);
@@ -13617,7 +13797,14 @@ class TaskList {
13617
13797
  this.navigate(1);
13618
13798
  }
13619
13799
  navigate(direction) {
13620
- this.currentIndex = (this.currentIndex + direction + this.items.length) % this.items.length;
13800
+ const allDone = this.items.every((item) => item.status !== 'pending');
13801
+ if (allDone)
13802
+ return;
13803
+ let next = (this.currentIndex + direction + this.items.length) % this.items.length;
13804
+ while (this.items[next].status !== 'pending') {
13805
+ next = (next + direction + this.items.length) % this.items.length;
13806
+ }
13807
+ this.currentIndex = next;
13621
13808
  }
13622
13809
  async start() {
13623
13810
  while (true) {
@@ -13630,6 +13817,9 @@ class TaskList {
13630
13817
  case 'complete':
13631
13818
  this.markAsComplete();
13632
13819
  break;
13820
+ case 'autofix':
13821
+ await this.autoFix();
13822
+ break;
13633
13823
  case 'skip':
13634
13824
  this.skip();
13635
13825
  break;
@@ -13647,43 +13837,39 @@ class TaskList {
13647
13837
  await this.displaySummary();
13648
13838
  return;
13649
13839
  }
13840
+ if (this.items.every((item) => item.status !== 'pending')) {
13841
+ this.rl.close();
13842
+ await this.displaySummary();
13843
+ return;
13844
+ }
13650
13845
  }
13651
13846
  }
13847
+ renderMenu() {
13848
+ const choices = this.getChoices();
13849
+ console.log(chalk.dim('Choose an action:'));
13850
+ choices.forEach((c) => console.log(` ${c.name}`));
13851
+ }
13652
13852
  getActionWithKeyboardShortcut() {
13853
+ this.renderMenu();
13653
13854
  return new Promise((resolve) => {
13654
- const keyHandler = (_, key) => {
13655
- if (key) {
13656
- switch (key.name) {
13657
- case 'o':
13658
- resolve('open');
13659
- break;
13660
- case 'd':
13661
- resolve('complete');
13662
- break;
13663
- case 's':
13664
- resolve('skip');
13665
- break;
13666
- case 'x':
13667
- resolve('omit');
13668
- break;
13669
- case 'right':
13670
- resolve('next');
13671
- break;
13672
- case 'left':
13673
- resolve('prev');
13674
- break;
13675
- case 'q':
13676
- resolve('exit');
13677
- break;
13678
- }
13679
- }
13855
+ const actionMap = {
13856
+ o: 'open',
13857
+ a: 'autofix',
13858
+ d: 'complete',
13859
+ s: 'skip',
13860
+ x: 'omit',
13861
+ right: 'next',
13862
+ left: 'prev',
13863
+ q: 'exit',
13680
13864
  };
13681
- readline.emitKeypressEvents(process.stdin);
13682
- process.stdin.on('keypress', keyHandler);
13683
- this.promptAction().then((action) => {
13684
- process.stdin.removeListener('keypress', keyHandler);
13865
+ const handleKeypress = (_, key) => {
13866
+ const action = key ? actionMap[key.name] : undefined;
13867
+ if (!action)
13868
+ return;
13869
+ process.stdin.removeListener('keypress', handleKeypress);
13685
13870
  resolve(action);
13686
- });
13871
+ };
13872
+ process.stdin.on('keypress', handleKeypress);
13687
13873
  });
13688
13874
  }
13689
13875
  async displaySummary() {
@@ -13744,6 +13930,9 @@ const REVIEW_PROMPT = new PromptTemplate({
13744
13930
  inputVariables,
13745
13931
  });
13746
13932
 
13933
+ // Some review prompts still produce a single feedback object. Normalize that shape
13934
+ // so the parser always returns an array for the rest of the review flow.
13935
+ const ReviewFeedbackResponseSchema = preprocess((value) => (Array.isArray(value) ? value : [value]), ReviewFeedbackItemArraySchema);
13747
13936
  const handler = async (argv, logger) => {
13748
13937
  const git = getRepo();
13749
13938
  const config = loadConfig(argv);
@@ -13844,7 +14033,7 @@ const handler = async (argv, logger) => {
13844
14033
  factory,
13845
14034
  parser,
13846
14035
  agent: async (context, options) => {
13847
- const parser = createSchemaParser(ReviewFeedbackItemArraySchema, llm);
14036
+ const parser = createSchemaParser(ReviewFeedbackResponseSchema, llm);
13848
14037
  const formatInstructions = "Respond with a valid JSON object, containing four fields:'title' a string, 'summary' a short summary of the problem (include line number if big file), 'severity' a numeric enum up to ten, 'category' an enum string, and 'filePath' a relative filepath to file as string.";
13849
14038
  const prompt = getPrompt({
13850
14039
  template: options.prompt,
@@ -13876,7 +14065,7 @@ const handler = async (argv, logger) => {
13876
14065
  process.exit(0);
13877
14066
  },
13878
14067
  });
13879
- const reviewer = new TaskList(recap);
14068
+ const reviewer = new TaskList(recap, { ...config, apiKey: key ?? undefined });
13880
14069
  await reviewer.start();
13881
14070
  };
13882
14071
 
package/dist/index.js CHANGED
@@ -68,7 +68,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
68
68
  /**
69
69
  * Current build version from package.json
70
70
  */
71
- const BUILD_VERSION = "0.30.0";
71
+ const BUILD_VERSION = "0.31.0";
72
72
 
73
73
  const isInteractive = (config) => {
74
74
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -950,6 +950,23 @@ const schema$1 = {
950
950
  "type": "boolean",
951
951
  "description": "Whether to include the current branch name in the commit prompt for context. When enabled, the current git branch name will be included in the prompt.",
952
952
  "default": true
953
+ },
954
+ "autoFixTool": {
955
+ "type": "string",
956
+ "description": "The AI CLI tool to use for auto-fixing review issues. Must match a registered adapter key (e.g. \"codex\", \"claude\", \"gemini\"). When unset, the auto-fix action is disabled."
957
+ },
958
+ "autoFixToolOptions": {
959
+ "type": "object",
960
+ "additionalProperties": {
961
+ "type": "string"
962
+ },
963
+ "description": "Additional key-value flags passed to the auto-fix CLI tool. Keys are flag names (without leading dashes); values are flag values.",
964
+ "examples": [
965
+ {
966
+ "model": "o4-mini",
967
+ "approval-mode": "auto-edit"
968
+ }
969
+ ]
953
970
  }
954
971
  },
955
972
  "required": [
@@ -5375,10 +5392,10 @@ class $ZodRegistry {
5375
5392
  }
5376
5393
  }
5377
5394
  // registries
5378
- function registry() {
5395
+ function registry$1() {
5379
5396
  return new $ZodRegistry();
5380
5397
  }
5381
- (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry());
5398
+ (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry$1());
5382
5399
  const globalRegistry = globalThis.__zod_globalRegistry;
5383
5400
 
5384
5401
  // @__NO_SIDE_EFFECTS__
@@ -7201,6 +7218,11 @@ function refine(fn, _params = {}) {
7201
7218
  function superRefine(fn) {
7202
7219
  return _superRefine(fn);
7203
7220
  }
7221
+ // preprocess
7222
+ // /** @deprecated Use `z.pipe()` and `z.transform()` instead. */
7223
+ function preprocess(fn, schema) {
7224
+ return pipe(transform(fn), schema);
7225
+ }
7204
7226
 
7205
7227
  const ChangelogResponseSchema = object({
7206
7228
  title: string(),
@@ -8232,7 +8254,20 @@ function getStatus(file, location = 'index') {
8232
8254
  return 'unknown';
8233
8255
  }
8234
8256
  }
8257
+ else if ('binary' in file && file.binary === true) {
8258
+ // DiffResultBinaryFile: has before/after, no changes/insertions/deletions
8259
+ if (file.file.includes('=>'))
8260
+ return 'renamed';
8261
+ if (file.before === 0 && file.after > 0)
8262
+ return 'added';
8263
+ if (file.after === 0 && file.before > 0)
8264
+ return 'deleted';
8265
+ if (file.before > 0 && file.after > 0)
8266
+ return 'modified';
8267
+ return 'untracked';
8268
+ }
8235
8269
  else if ('changes' in file && 'binary' in file) {
8270
+ // DiffResultTextFile: has changes/insertions/deletions
8236
8271
  if (file.changes === 0)
8237
8272
  return 'untracked';
8238
8273
  if (file.file.includes('=>'))
@@ -12147,15 +12182,47 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
12147
12182
  return summary;
12148
12183
  }
12149
12184
 
12185
+ /**
12186
+ * Detects whether a GitError was caused by a pre-commit hook that modified files.
12187
+ * These hooks (e.g. black, prettier) reformat files and exit non-zero on the first run,
12188
+ * but the commit can succeed once the modified files are re-staged.
12189
+ */
12190
+ function isPreCommitHookModifiedFilesError(error) {
12191
+ if (!(error instanceof Error))
12192
+ return false;
12193
+ const msg = error.message;
12194
+ // pre-commit framework outputs "files were modified by this hook"
12195
+ // git itself outputs "modified files exist in working tree" in some hook setups
12196
+ return (msg.includes('files were modified by this hook') ||
12197
+ msg.includes('modified by this hook') ||
12198
+ msg.includes('hook id:'));
12199
+ }
12150
12200
  /**
12151
12201
  * Creates a commit with the specified commit message.
12202
+ * Handles the case where pre-commit hooks modify files (e.g. black, prettier):
12203
+ * when detected, stages the hook-modified files and retries the commit once.
12152
12204
  *
12153
12205
  * @param message The commit message.
12154
12206
  * @param git The SimpleGit instance.
12207
+ * @param onHookModifiedFiles Optional callback invoked before the auto-retry so callers can notify the user.
12155
12208
  * @returns A Promise that resolves to the CommitResult.
12156
12209
  */
12157
- async function createCommit(message, git) {
12158
- return await git.commit(message);
12210
+ async function createCommit(message, git, onHookModifiedFiles) {
12211
+ try {
12212
+ return await git.commit(message);
12213
+ }
12214
+ catch (error) {
12215
+ if (isPreCommitHookModifiedFilesError(error)) {
12216
+ // Notify caller (e.g. to show a spinner message or log)
12217
+ if (onHookModifiedFiles) {
12218
+ await onHookModifiedFiles();
12219
+ }
12220
+ // Stage all hook-modified files and retry the commit once
12221
+ await git.add('.');
12222
+ return await git.commit(message);
12223
+ }
12224
+ throw error;
12225
+ }
12159
12226
  }
12160
12227
 
12161
12228
  /**
@@ -12734,7 +12801,9 @@ IMPORTANT RULES:
12734
12801
  handleResult({
12735
12802
  result: commitMsg,
12736
12803
  interactiveModeCallback: async (result) => {
12737
- await createCommit(result, git);
12804
+ await createCommit(result, git, () => {
12805
+ logger.log('⚠️ Pre-commit hook modified files. Staging changes and retrying commit...', { color: 'yellow' });
12806
+ });
12738
12807
  logSuccess();
12739
12808
  },
12740
12809
  mode: MODE,
@@ -13585,15 +13654,103 @@ const builder = (yargs) => {
13585
13654
  return yargs.options(options).usage(getCommandUsageHeader(command));
13586
13655
  };
13587
13656
 
13657
+ async function buildPrompt(item) {
13658
+ const ext = path__namespace.extname(item.filePath).slice(1);
13659
+ let fileSection;
13660
+ try {
13661
+ const contents = await fs__namespace.promises.readFile(item.filePath, 'utf-8');
13662
+ fileSection = `\`\`\`${ext}\n${contents}\n\`\`\``;
13663
+ }
13664
+ catch {
13665
+ fileSection = `[WARNING: File "${item.filePath}" was not found on disk. Fix based on the issue description alone.]`;
13666
+ }
13667
+ return `You are an expert software engineer. Fix the following code review issue.
13668
+
13669
+ ## Issue
13670
+ Title: ${item.title}
13671
+ Category: ${item.category}
13672
+ Severity: ${item.severity}/10
13673
+ File: ${item.filePath}
13674
+
13675
+ ## Problem
13676
+ ${item.summary}
13677
+
13678
+ ## File Contents
13679
+ ${fileSection}
13680
+
13681
+ Fix the issue described above. Make only the changes necessary to resolve this specific problem.`;
13682
+ }
13683
+
13684
+ class CodexAdapter {
13685
+ async run(prompt, options, apiKey) {
13686
+ const args = ['exec'];
13687
+ if (options) {
13688
+ for (const [key, value] of Object.entries(options)) {
13689
+ if (key === 'model' || key === 'm') {
13690
+ args.push('--model', value);
13691
+ }
13692
+ else if (key === 'sandbox' || key === 's') {
13693
+ args.push('--sandbox', value);
13694
+ }
13695
+ else {
13696
+ args.push('-c', `${key}=${value}`);
13697
+ }
13698
+ }
13699
+ }
13700
+ args.push('--full-auto', prompt);
13701
+ // Preserve the caller's environment by default and only override the API key
13702
+ // when an explicit non-empty key is provided through auto-fix config.
13703
+ const env = { ...process.env };
13704
+ if (apiKey)
13705
+ env['OPENAI_API_KEY'] = apiKey;
13706
+ return new Promise((resolve, reject) => {
13707
+ const child = child_process.spawn('codex', args, { stdio: 'inherit', env });
13708
+ child.on('error', (err) => {
13709
+ if (err.code === 'ENOENT') {
13710
+ reject(new Error('codex binary not found. Please install it: npm i -g @openai/codex'));
13711
+ }
13712
+ else {
13713
+ reject(err);
13714
+ }
13715
+ });
13716
+ child.on('close', (code) => {
13717
+ if (code === 0) {
13718
+ resolve();
13719
+ }
13720
+ else {
13721
+ reject(new Error(`codex exited with code ${code}`));
13722
+ }
13723
+ });
13724
+ });
13725
+ }
13726
+ }
13727
+
13728
+ const registry = {
13729
+ codex: new CodexAdapter(),
13730
+ };
13731
+ async function runAutoFix(item, config) {
13732
+ if (!config.autoFixTool) {
13733
+ return;
13734
+ }
13735
+ const adapter = registry[config.autoFixTool];
13736
+ if (!adapter) {
13737
+ throw new Error(`Unknown autoFixTool: "${config.autoFixTool}"`);
13738
+ }
13739
+ const prompt = await buildPrompt(item);
13740
+ await adapter.run(prompt, config.autoFixToolOptions, config.apiKey);
13741
+ }
13742
+
13588
13743
  class TaskList {
13589
- constructor(items) {
13744
+ constructor(items, config) {
13590
13745
  this.currentIndex = 0;
13591
13746
  this.items = items.map((item) => ({ ...item, status: 'pending' }));
13747
+ this.config = config;
13592
13748
  this.rl = readline__namespace.createInterface({
13593
13749
  input: process.stdin,
13594
13750
  output: process.stdout,
13595
13751
  });
13596
13752
  process.stdin.setRawMode(true);
13753
+ readline__namespace.emitKeypressEvents(process.stdin);
13597
13754
  }
13598
13755
  async displayCurrentItem() {
13599
13756
  const item = this.items[this.currentIndex];
@@ -13610,18 +13767,12 @@ class TaskList {
13610
13767
  return [
13611
13768
  { name: `✅ Mark as complete ${hotKey('d')}`, value: 'complete' },
13612
13769
  { name: `📂 Open file ${hotKey('o')}`, value: 'open' },
13770
+ { name: `🤖 Auto-fix ${hotKey('a')}`, value: 'autofix' },
13613
13771
  { name: `⏩ Skip ${hotKey('s')}`, value: 'skip' },
13614
13772
  { name: `🙈 Omit ${hotKey('x')}`, value: 'omit' },
13615
13773
  { name: `${exitText} ${hotKey('q')}`, value: 'exit' },
13616
13774
  ];
13617
13775
  }
13618
- async promptAction() {
13619
- const action = await prompts.select({
13620
- message: 'Choose an action:',
13621
- choices: this.getChoices(),
13622
- });
13623
- return action;
13624
- }
13625
13776
  async openFile() {
13626
13777
  const item = this.items[this.currentIndex];
13627
13778
  await execPromise(`${process.env.EDITOR || 'code'} ${item.filePath}`);
@@ -13630,6 +13781,35 @@ class TaskList {
13630
13781
  this.items[this.currentIndex].status = 'completed';
13631
13782
  this.navigate(1);
13632
13783
  }
13784
+ async autoFix() {
13785
+ if (!this.config?.autoFixTool) {
13786
+ console.log(chalk.yellow('No autoFixTool configured. Set "autoFixTool" in .coco.config.json'));
13787
+ return;
13788
+ }
13789
+ const item = this.items[this.currentIndex];
13790
+ console.clear();
13791
+ console.log(chalk.bold.cyan(`🤖 Running auto-fix: ${item.title}`));
13792
+ console.log(chalk.dim(`File: ${item.filePath}\n`));
13793
+ // Fully release terminal control before handing off to child process
13794
+ process.stdin.setRawMode(false);
13795
+ process.stdin.pause();
13796
+ await new Promise((r) => setTimeout(r, 50));
13797
+ try {
13798
+ await runAutoFix(item, this.config);
13799
+ this.markAsComplete();
13800
+ console.log(chalk.green('\n✅ Auto-fix completed successfully.'));
13801
+ await new Promise((r) => setTimeout(r, 1200));
13802
+ }
13803
+ catch (err) {
13804
+ console.log(chalk.red(`\n❌ Auto-fix failed: ${err.message}`));
13805
+ await new Promise((r) => setTimeout(r, 1800));
13806
+ }
13807
+ finally {
13808
+ // Restore terminal for our keypress handler
13809
+ process.stdin.resume();
13810
+ process.stdin.setRawMode(true);
13811
+ }
13812
+ }
13633
13813
  skip() {
13634
13814
  this.items[this.currentIndex].status = 'skipped';
13635
13815
  this.navigate(1);
@@ -13639,7 +13819,14 @@ class TaskList {
13639
13819
  this.navigate(1);
13640
13820
  }
13641
13821
  navigate(direction) {
13642
- this.currentIndex = (this.currentIndex + direction + this.items.length) % this.items.length;
13822
+ const allDone = this.items.every((item) => item.status !== 'pending');
13823
+ if (allDone)
13824
+ return;
13825
+ let next = (this.currentIndex + direction + this.items.length) % this.items.length;
13826
+ while (this.items[next].status !== 'pending') {
13827
+ next = (next + direction + this.items.length) % this.items.length;
13828
+ }
13829
+ this.currentIndex = next;
13643
13830
  }
13644
13831
  async start() {
13645
13832
  while (true) {
@@ -13652,6 +13839,9 @@ class TaskList {
13652
13839
  case 'complete':
13653
13840
  this.markAsComplete();
13654
13841
  break;
13842
+ case 'autofix':
13843
+ await this.autoFix();
13844
+ break;
13655
13845
  case 'skip':
13656
13846
  this.skip();
13657
13847
  break;
@@ -13669,43 +13859,39 @@ class TaskList {
13669
13859
  await this.displaySummary();
13670
13860
  return;
13671
13861
  }
13862
+ if (this.items.every((item) => item.status !== 'pending')) {
13863
+ this.rl.close();
13864
+ await this.displaySummary();
13865
+ return;
13866
+ }
13672
13867
  }
13673
13868
  }
13869
+ renderMenu() {
13870
+ const choices = this.getChoices();
13871
+ console.log(chalk.dim('Choose an action:'));
13872
+ choices.forEach((c) => console.log(` ${c.name}`));
13873
+ }
13674
13874
  getActionWithKeyboardShortcut() {
13875
+ this.renderMenu();
13675
13876
  return new Promise((resolve) => {
13676
- const keyHandler = (_, key) => {
13677
- if (key) {
13678
- switch (key.name) {
13679
- case 'o':
13680
- resolve('open');
13681
- break;
13682
- case 'd':
13683
- resolve('complete');
13684
- break;
13685
- case 's':
13686
- resolve('skip');
13687
- break;
13688
- case 'x':
13689
- resolve('omit');
13690
- break;
13691
- case 'right':
13692
- resolve('next');
13693
- break;
13694
- case 'left':
13695
- resolve('prev');
13696
- break;
13697
- case 'q':
13698
- resolve('exit');
13699
- break;
13700
- }
13701
- }
13877
+ const actionMap = {
13878
+ o: 'open',
13879
+ a: 'autofix',
13880
+ d: 'complete',
13881
+ s: 'skip',
13882
+ x: 'omit',
13883
+ right: 'next',
13884
+ left: 'prev',
13885
+ q: 'exit',
13702
13886
  };
13703
- readline__namespace.emitKeypressEvents(process.stdin);
13704
- process.stdin.on('keypress', keyHandler);
13705
- this.promptAction().then((action) => {
13706
- process.stdin.removeListener('keypress', keyHandler);
13887
+ const handleKeypress = (_, key) => {
13888
+ const action = key ? actionMap[key.name] : undefined;
13889
+ if (!action)
13890
+ return;
13891
+ process.stdin.removeListener('keypress', handleKeypress);
13707
13892
  resolve(action);
13708
- });
13893
+ };
13894
+ process.stdin.on('keypress', handleKeypress);
13709
13895
  });
13710
13896
  }
13711
13897
  async displaySummary() {
@@ -13766,6 +13952,9 @@ const REVIEW_PROMPT = new prompts$1.PromptTemplate({
13766
13952
  inputVariables,
13767
13953
  });
13768
13954
 
13955
+ // Some review prompts still produce a single feedback object. Normalize that shape
13956
+ // so the parser always returns an array for the rest of the review flow.
13957
+ const ReviewFeedbackResponseSchema = preprocess((value) => (Array.isArray(value) ? value : [value]), ReviewFeedbackItemArraySchema);
13769
13958
  const handler = async (argv, logger) => {
13770
13959
  const git = getRepo();
13771
13960
  const config = loadConfig(argv);
@@ -13866,7 +14055,7 @@ const handler = async (argv, logger) => {
13866
14055
  factory,
13867
14056
  parser,
13868
14057
  agent: async (context, options) => {
13869
- const parser = createSchemaParser(ReviewFeedbackItemArraySchema, llm);
14058
+ const parser = createSchemaParser(ReviewFeedbackResponseSchema, llm);
13870
14059
  const formatInstructions = "Respond with a valid JSON object, containing four fields:'title' a string, 'summary' a short summary of the problem (include line number if big file), 'severity' a numeric enum up to ten, 'category' an enum string, and 'filePath' a relative filepath to file as string.";
13871
14060
  const prompt = getPrompt({
13872
14061
  template: options.prompt,
@@ -13898,7 +14087,7 @@ const handler = async (argv, logger) => {
13898
14087
  process.exit(0);
13899
14088
  },
13900
14089
  });
13901
- const reviewer = new TaskList(recap);
14090
+ const reviewer = new TaskList(recap, { ...config, apiKey: key ?? undefined });
13902
14091
  await reviewer.start();
13903
14092
  };
13904
14093
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",