guito 1.0.6 → 1.0.7

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to Guito are documented in this file. The project follows [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.7] - 2026-10-01
8
+
9
+ ### Added
10
+
11
+ - Draft commit messages with Claude. A new **Generate message** button in the uncommitted-changes panel hands the pending changes to Claude Code running on this machine and fills the commit message and description boxes with the reply, matching the language and style of your recent commits. The **Commit message model** setting (`guito.aiReview.commitMessageModel` inside the extension) picks the model; haiku is the default, since a subject line is a small question. Nothing is committed automatically: the draft lands in the boxes for you to review and edit, and Guito falls back to every uncommitted change when nothing is staged.
12
+ - Choose which Claude model reviews your pull requests. A new **Claude model** setting (the `guito.aiReview.model` VS Code setting inside the extension) sets the default for the automated reviewer, and the model picker in the pull request dialog's **Review** tab — preselected from that setting — overrides it for a single run; the review's summary line shows which model produced it. Aliases like "opus" always mean the latest model of that family Claude Code has.
13
+ - Scrolling to the end of the commit history now loads the next page automatically, so long histories can be explored without touching the footer; the **Load more commits** and **Load all** buttons remain available for explicit control.
14
+ - Pull request completion and auto-complete now follow the target branch's merge policy. Guito reads the project's Azure DevOps branch policies and offers only the merge strategies the "Merge Types" policies covering the pull request allow (so a squash-only branch no longer lists the other strategies, which Azure would reject anyway), notes in the dialog when the list is limited by policy, and still applies the **Allow merge completion** setting on top. Branches without a merge policy keep offering every strategy.
15
+
16
+ ### Fixed
17
+
18
+ - Canceling auto-complete on an Azure DevOps pull request now verifies the server actually cleared it. Azure DevOps sometimes answers the clearing PATCH with 200 OK while silently leaving auto-complete enabled, which made the button appear to do nothing; Guito now checks the pull request the server returns, retries with the alternate payload shape, and reports an error when auto-complete is still set.
19
+
20
+ ### Removed
21
+
22
+ - The `guito.aiReview.claudeArgs` setting. Models are chosen with the new model settings instead: `guito.aiReview.model` for pull request reviews and `guito.aiReview.commitMessageModel` for commit messages.
23
+
7
24
  ## [1.0.6] - 2026-09-24
8
25
 
9
26
  ### Added
package/README.md CHANGED
@@ -60,6 +60,8 @@ Click **Uncommitted changes** to open the staged and unstaged file lists. Click
60
60
 
61
61
  Enter a commit message and optional description, then choose **Commit staged changes**. Only staged changes are committed; unstaged edits remain on disk. Commit drafts stay available when you close and reopen the panel during the session.
62
62
 
63
+ **Generate message** drafts a subject and description with Claude Code running on your machine, from the staged diff when anything is staged and from every uncommitted change otherwise, matching the language and style of your recent commits. The draft fills the boxes for you to review and edit; nothing is committed automatically. The **Commit message model** setting picks the model.
64
+
63
65
  ## CLI options
64
66
 
65
67
  ```text
@@ -83,6 +85,40 @@ Run the command from anywhere inside the Git worktree you want Guito to manage.
83
85
 
84
86
  The standalone server can execute Git commands against the current repository. Keep it on a trusted machine and do not expose its port to untrusted networks.
85
87
 
88
+ ## Settings
89
+
90
+ Inside the VS Code extension, every preference below is a native VS Code setting in the `guito` namespace: open the Settings editor and search for "Guito". In the browser client, the same preferences live in the settings dialog behind the toolbar's gear icon and are stored per repository in `.git/guito-settings.json`, which is never committed. VS Code settings win over the stored file when both are set.
91
+
92
+ The settings dialog also manages preferences that have no VS Code equivalent: the repository's Git user name and email, the fetch and push URLs of the configured remotes, and issue-linking rules. An issue-linking rule (a regular expression plus a URL template) turns issue references in commit messages into links; it can be saved for the repository or globally, and global rules are stored in your Git configuration as `guito.issueRegex` and `guito.issueUrl`.
93
+
94
+ ### General
95
+
96
+ | Setting | Description |
97
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `guito.autoReload` | Reload commits and the working tree automatically when the repository changes outside Guito, for example from a terminal. On by default. |
99
+ | `guito.showGraph` | Show the Git graph beside the commit history. On by default. |
100
+ | `guito.showStashes` | Show stash entries above the commit history. Off by default. |
101
+ | `guito.showTags` | Show tag badges attached to commits. On by default. |
102
+ | `guito.showRemoteBranches` | Show remote branches in the repository panel and the branch pills on commits. On by default. |
103
+ | `guito.fileListView` | How changed-file lists are shown: `flat` (default) lists every file with its full path, `tree` groups them into collapsible folders. |
104
+ | `guito.refListView` | How branches and tags are shown in the repository panel: `flat` (default) lists full names, `tree` groups them into collapsible namespace folders. |
105
+ | `guito.sidePanelSectionsExpanded`| Open the repository panel with all sections expanded. On by default. |
106
+ | `guito.searchMode` | How commit search behaves: `navigate` (default) keeps the full history and jumps between matches, `filter` shows only matching commits. |
107
+ | `guito.searchCaseSensitive` | Match commit and repository panel searches using the query's exact letter casing and accents. Off by default. |
108
+ | `guito.diffViewer` | Where file diffs open: `vscode` (default) uses the native VS Code diff tab, `guito` uses Guito's own dialog. Binary files always use Guito's dialog. |
109
+ | `guito.issueRegex` | Regular expression that recognizes issue references in commit messages, for example `#(\d+)`. Inside the extension this is the issue-linking rule; it wins over the rule saved in the settings dialog. |
110
+ | `guito.issueUrl` | URL template for issue links; capture groups from the issue regex are inserted with `$1`, `$2`, and so on. |
111
+
112
+ ### Azure DevOps
113
+
114
+ | Setting | Description |
115
+ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `guito.azureDevOpsUrl` | Base URL of your on-prem Azure DevOps Server, for example `https://server/DefaultCollection`. Enables pull request creation and the automated reviewer; requests authenticate with Windows integrated auth. |
117
+ | `guito.prBranchNameTemplate` | Branch name template used when Guito creates a new branch for a pull request. Defaults to `pr/${randomstring}`. Variables: `${username}`, `${randomstring}`, `${branch}`, `${targetbranch}`, `${title}`, `${repository}`, `${date}` (UTC `YYYY-MM-DD`), `${time}` (UTC `HHmmss`), and `${timestamp}`. |
118
+ | `guito.allowMerge` | Offer the "No fast-forward (merge commit)" and "Semi-linear merge" pull request completion strategies. On by default. |
119
+
120
+ The `guito.aiReview.*` settings that configure the automated pull request reviewer are listed under [Automated pull request review](#automated-pull-request-review).
121
+
86
122
  ## Automated pull request review
87
123
 
88
124
  Guito can hand your Azure DevOps pull requests to [Claude Code](https://claude.com/claude-code) running on your own machine, then queue what it finds for you to approve.
@@ -103,10 +139,11 @@ Inside VS Code the reviewer also runs while no Guito panel is open, so pull requ
103
139
  | -------------------------------- | ---------------------------------------------------------------------------------------- |
104
140
  | `guito.aiReview.enabled` | Turns automated review on. Off by default. |
105
141
  | `guito.aiReview.scope` | `reviewer` (default), `mine`, or `all`. |
106
- | `guito.aiReview.pollMinutes` | Minutes between checks. Defaults to 5. |
142
+ | `guito.aiReview.pollMinutes` | Minutes between checks. Defaults to 30. |
107
143
  | `guito.aiReview.includeDrafts` | Also review draft pull requests. Off by default. |
108
144
  | `guito.aiReview.claudePath` | Path to the Claude Code executable. Empty means Guito looks for it (see above). |
109
- | `guito.aiReview.claudeArgs` | Extra Claude Code arguments, for example `["--model", "opus"]`. |
145
+ | `guito.aiReview.model` | Which Claude model reviews pull requests. Default uses whatever Claude Code is set to. |
146
+ | `guito.aiReview.commitMessageModel` | Which Claude model drafts commit messages. Defaults to haiku. |
110
147
  | `guito.aiReview.timeoutSeconds` | How long one review may take. Defaults to 600. |
111
148
  | `guito.aiReview.instructions` | Extra reviewing instructions, such as your team's conventions. |
112
149
 
package/bin/ai-review.js CHANGED
@@ -6,10 +6,11 @@ const SEVERITIES = ['blocker', 'concern', 'suggestion', 'nit'];
6
6
  export const DEFAULT_AI_REVIEW_CONFIG = {
7
7
  enabled: false,
8
8
  scope: 'reviewer',
9
- pollMinutes: 5,
9
+ pollMinutes: 30,
10
10
  includeDrafts: false,
11
11
  claudePath: '',
12
- claudeArgs: [],
12
+ model: '',
13
+ commitMessageModel: 'haiku',
13
14
  timeoutSeconds: 600,
14
15
  maxDiffChars: 300000,
15
16
  instructions: '',
@@ -33,8 +34,11 @@ export function resolveAiReviewConfig(...sources) {
33
34
  if (typeof source.claudePath === 'string' && source.claudePath.trim()) {
34
35
  config.claudePath = source.claudePath.trim();
35
36
  }
36
- if (Array.isArray(source.claudeArgs)) {
37
- config.claudeArgs = source.claudeArgs.map((arg) => String(arg)).filter(Boolean);
37
+ if (typeof source.model === 'string') {
38
+ config.model = source.model.trim();
39
+ }
40
+ if (typeof source.commitMessageModel === 'string') {
41
+ config.commitMessageModel = source.commitMessageModel.trim();
38
42
  }
39
43
  if (Number.isFinite(source.timeoutSeconds)) {
40
44
  config.timeoutSeconds = Math.min(Math.max(Number(source.timeoutSeconds), 30), 3600);
@@ -83,12 +87,7 @@ async function bundledClaudeCandidates() {
83
87
  const home = homedir();
84
88
  const binary = process.platform === 'win32' ? 'claude.exe' : 'claude';
85
89
  const found = [];
86
- for (const root of [
87
- '.vscode',
88
- '.vscode-insiders',
89
- '.vscode-server',
90
- '.vscode-server-insiders',
91
- ]) {
90
+ for (const root of ['.vscode', '.vscode-insiders', '.vscode-server', '.vscode-server-insiders']) {
92
91
  const extensions = join(home, root, 'extensions');
93
92
  let entries;
94
93
  try {
@@ -132,8 +131,9 @@ export async function resolveClaudeCommand(configured) {
132
131
  /**
133
132
  * Runs the Claude Code CLI in print mode. The prompt goes over stdin because a
134
133
  * pull request diff is far larger than the Windows command line allows.
134
+ * Exported because the commit message drafter runs the CLI the same way.
135
135
  */
136
- const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise((resolve, reject) => {
136
+ export const runClaudeCli = (prompt, { command, args, cwd, timeoutMs, signal }) => new Promise((resolve, reject) => {
137
137
  const child = spawn(command, args, {
138
138
  cwd,
139
139
  windowsHide: true,
@@ -148,6 +148,7 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
148
148
  return;
149
149
  settled = true;
150
150
  clearTimeout(timer);
151
+ signal?.removeEventListener('abort', onAbort);
151
152
  if (error)
152
153
  reject(error);
153
154
  else
@@ -157,6 +158,16 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
157
158
  child.kill();
158
159
  finish(new Error(`The review timed out after ${Math.round(timeoutMs / 1000)}s.`));
159
160
  }, timeoutMs);
161
+ const onAbort = () => {
162
+ child.kill();
163
+ finish(new Error('The review was cancelled.'));
164
+ };
165
+ if (signal) {
166
+ if (signal.aborted)
167
+ onAbort();
168
+ else
169
+ signal.addEventListener('abort', onAbort);
170
+ }
160
171
  child.stdout.on('data', (chunk) => {
161
172
  stdout += String(chunk);
162
173
  });
@@ -339,9 +350,7 @@ export function buildReviewPrompt(input) {
339
350
  'EXISTING COMMENT THREADS ON THE PULL REQUEST:',
340
351
  renderThreads(threads),
341
352
  '',
342
- input.skipped.length
343
- ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n`
344
- : '',
353
+ input.skipped.length ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n` : '',
345
354
  input.instructions.trim() ? `TEAM INSTRUCTIONS:\n${input.instructions.trim()}\n` : '',
346
355
  REVIEW_CONTRACT,
347
356
  '',
@@ -371,6 +380,7 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
371
380
  const running = new Set();
372
381
  let timer;
373
382
  let activePoll = null;
383
+ let pollAbort;
374
384
  // One review at a time: each one spawns a CLI and burns Azure DevOps calls.
375
385
  let queue = Promise.resolve();
376
386
  const serialize = (action) => {
@@ -422,7 +432,10 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
422
432
  const rawLine = Number(entry?.line);
423
433
  const line = inDiff && Number.isInteger(rawLine) && anchors.get(file).has(rawLine) ? rawLine : null;
424
434
  const rawEnd = Number(entry?.endLine);
425
- const endLine = line !== null && Number.isInteger(rawEnd) && rawEnd >= line && anchors.get(file).has(rawEnd)
435
+ const endLine = line !== null &&
436
+ Number.isInteger(rawEnd) &&
437
+ rawEnd >= line &&
438
+ anchors.get(file).has(rawEnd)
426
439
  ? rawEnd
427
440
  : null;
428
441
  const severity = SEVERITIES.includes(entry?.severity)
@@ -451,9 +464,23 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
451
464
  return accepted.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
452
465
  };
453
466
  const runReview = async (pullRequestId, options = {}) => {
467
+ // A pass the poller cancelled before this review's turn must not start.
468
+ if (options.signal?.aborted) {
469
+ return (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
470
+ }
454
471
  const pullRequest = await deps.loadPullRequest(pullRequestId);
455
472
  const previousState = (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
473
+ // A per-review model choice wins over the configured one. An alias such
474
+ // as "opus" always means the latest model of that family Claude Code has.
475
+ const model = typeof options.model === 'string' && options.model.trim()
476
+ ? options.model.trim()
477
+ : config.model;
478
+ // A different model earns a fresh look at code already reviewed at this
479
+ // commit; when new commits exist, they alone are reviewed with it.
480
+ const freshLook = (previousState.model ?? '') !== model &&
481
+ previousState.reviewedCommit === pullRequest.sourceCommit;
456
482
  if (!options.force &&
483
+ !freshLook &&
457
484
  previousState.reviewedCommit &&
458
485
  previousState.reviewedCommit === pullRequest.sourceCommit) {
459
486
  return previousState;
@@ -495,18 +522,14 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
495
522
  instructions: config.instructions,
496
523
  });
497
524
  const command = await resolveClaudeCommand(config.claudePath);
498
- const args = [
499
- '-p',
500
- '--output-format',
501
- 'json',
502
- ...(config.claudeArgs.length ? config.claudeArgs : []),
503
- ];
504
- log(`AI review: reviewing pull request ${pullRequestId} with ${command}`);
525
+ const args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])];
526
+ log(`AI review: reviewing pull request ${pullRequestId} with ${command}${model ? ` (${model})` : ''}`);
505
527
  const raw = await runModel(prompt, {
506
528
  command,
507
529
  args,
508
530
  cwd: repositoryPath,
509
531
  timeoutMs: config.timeoutSeconds * 1000,
532
+ signal: options.signal,
510
533
  });
511
534
  const review = parseModelReview(raw);
512
535
  summary = review.summary;
@@ -521,6 +544,7 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
521
544
  ...current,
522
545
  title: pullRequest.title,
523
546
  reviewedCommit: pullRequest.sourceCommit,
547
+ model,
524
548
  reviewedAt: now().toISOString(),
525
549
  passes: current.passes + 1,
526
550
  status: 'idle',
@@ -539,16 +563,23 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
539
563
  return next;
540
564
  }
541
565
  catch (error) {
566
+ const cancelled = options.signal?.aborted === true;
542
567
  const message = error?.message ? String(error.message) : String(error);
543
- log(`AI review: pull request ${pullRequestId} failed: ${message}`);
568
+ log(cancelled
569
+ ? `AI review: pull request ${pullRequestId} was cancelled.`
570
+ : `AI review: pull request ${pullRequestId} failed: ${message}`);
544
571
  return mutateStore((store) => {
545
572
  const current = store.pullRequests[String(pullRequestId)] ?? previousState;
546
- const state = {
547
- ...current,
548
- status: 'error',
549
- error: message,
550
- errorCommit: pullRequest.sourceCommit,
551
- };
573
+ // A cancelled review is not a failure: the pull request is left as it
574
+ // was, so switching the reviewer back on reviews the same commit.
575
+ const state = cancelled
576
+ ? { ...current, status: 'idle', error: '' }
577
+ : {
578
+ ...current,
579
+ status: 'error',
580
+ error: message,
581
+ errorCommit: pullRequest.sourceCommit,
582
+ };
552
583
  store.pullRequests[String(pullRequestId)] = state;
553
584
  return state;
554
585
  });
@@ -558,12 +589,14 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
558
589
  }
559
590
  };
560
591
  /** One pass over the pull requests in scope; reviews those with new code. */
561
- const runPoll = async () => {
592
+ const runPoll = async (signal) => {
562
593
  const reviewed = [];
563
594
  try {
564
595
  const pullRequests = (await deps.listPullRequests()).filter(wanted);
565
596
  const store = await deps.readStore();
566
597
  for (const pullRequest of pullRequests) {
598
+ if (signal?.aborted)
599
+ break;
567
600
  if (!pullRequest.sourceCommit)
568
601
  continue;
569
602
  const state = store.pullRequests?.[String(pullRequest.id)];
@@ -573,8 +606,8 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
573
606
  continue;
574
607
  if (state?.status === 'error' && state.errorCommit === pullRequest.sourceCommit)
575
608
  continue;
576
- const next = await serialize(() => runReview(pullRequest.id));
577
- if (next.status !== 'error')
609
+ const next = await serialize(() => runReview(pullRequest.id, { signal }));
610
+ if (!signal?.aborted && next.status !== 'error')
578
611
  reviewed.push(pullRequest.id);
579
612
  }
580
613
  }
@@ -592,6 +625,11 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
592
625
  return pullRequest.isMine;
593
626
  return pullRequest.isReviewer;
594
627
  };
628
+ const clearTimer = () => {
629
+ if (timer)
630
+ clearInterval(timer);
631
+ timer = undefined;
632
+ };
595
633
  const reviewer = {
596
634
  updateConfig(next) {
597
635
  const wasEnabled = config.enabled;
@@ -683,22 +721,24 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
683
721
  return Promise.resolve([]);
684
722
  // A caller asking for a poll while one runs waits for that pass rather
685
723
  // than being told, misleadingly, that there was nothing to review.
686
- activePoll ?? (activePoll = runPoll().finally(() => {
724
+ activePoll ?? (activePoll = runPoll((pollAbort = new AbortController()).signal).finally(() => {
687
725
  activePoll = null;
726
+ pollAbort = undefined;
688
727
  }));
689
728
  return activePoll;
690
729
  },
691
730
  start() {
692
- reviewer.stop();
731
+ clearTimer();
693
732
  if (!config.enabled)
694
733
  return;
695
734
  timer = setInterval(() => void reviewer.poll(), config.pollMinutes * 60000);
696
735
  timer.unref?.();
697
736
  },
698
737
  stop() {
699
- if (timer)
700
- clearInterval(timer);
701
- timer = undefined;
738
+ clearTimer();
739
+ // Switching the reviewer off also cancels the pass in flight: the CLI
740
+ // run is aborted and the pass's remaining pull requests are skipped.
741
+ pollAbort?.abort();
702
742
  },
703
743
  onFindings(listener) {
704
744
  listeners.add(listener);
@@ -0,0 +1,146 @@
1
+ import { resolveClaudeCommand, runClaudeCli, } from './ai-review.js';
2
+ /**
3
+ * One-shot commit message drafting.
4
+ *
5
+ * The pending changes are handed to the Claude Code CLI running on this
6
+ * machine (Claude Haiku by default: a subject line is cheap), and the reply
7
+ * fills the working panel's message box. Nothing is committed — the user
8
+ * still reviews and edits the text before pressing the commit button.
9
+ */
10
+ /** Alias handed to `--model`; '' would leave the choice to Claude Code. */
11
+ export const DEFAULT_COMMIT_MESSAGE_MODEL = 'haiku';
12
+ /** A message draft is a small question; it must not outstay the review budget. */
13
+ const COMMIT_MESSAGE_TIMEOUT_SECONDS = 120;
14
+ /** Haiku does not need the whole diff budget the reviewer configures. */
15
+ const COMMIT_MESSAGE_MAX_DIFF_CHARS = 100000;
16
+ const oneLine = (value) => String(value ?? '')
17
+ .replace(/\s+/g, ' ')
18
+ .trim();
19
+ /** Renders one file's diff compactly; only +/- markers, no line numbers. */
20
+ function renderFile(file, budget) {
21
+ const renamed = file.oldPath && file.oldPath !== file.path ? ` (renamed from ${file.oldPath})` : '';
22
+ const header = `--- ${file.path}${renamed} [${file.status}] ---`;
23
+ if (file.status === 'binary' || !file.lines.length)
24
+ return `${header}\n(binary or empty diff)`;
25
+ const rows = [header];
26
+ let used = header.length;
27
+ let truncated = false;
28
+ for (const line of file.lines) {
29
+ const row = line.type === 'add'
30
+ ? `+${line.text}`
31
+ : line.type === 'del'
32
+ ? `-${line.text}`
33
+ : ` ${line.text}`;
34
+ if (used + row.length + 1 > budget) {
35
+ truncated = true;
36
+ break;
37
+ }
38
+ rows.push(row);
39
+ used += row.length + 1;
40
+ }
41
+ if (truncated)
42
+ rows.push('(diff truncated: file too large)');
43
+ return rows.join('\n');
44
+ }
45
+ const COMMIT_MESSAGE_CONTRACT = `Reply with one JSON object and nothing else:
46
+ {"subject": "<one line, imperative mood, at most 72 characters, no trailing period>",
47
+ "description": "<body explaining what and why, wrapped at 72 characters; \\"\\" when the subject alone is clear>"}
48
+
49
+ Rules:
50
+ - Summarize the change as a whole; do not walk through the diff file by file.
51
+ - Match the language and style of the recent commit subjects when they are given.
52
+ - Do not invent ticket numbers, prefixes or attribution the diff does not show.
53
+ - Answer only from the diff below. Do not use tools and do not ask questions.`;
54
+ /** Builds the drafting prompt for the pending changes. */
55
+ export function buildCommitMessagePrompt(input) {
56
+ const perFile = Math.max(2000, Math.floor(input.maxDiffChars / Math.max(input.files.length, 1)));
57
+ const body = input.files.map((file) => renderFile(file, perFile)).join('\n');
58
+ const sections = [
59
+ 'You are writing the commit message for changes in a Git repository.',
60
+ '',
61
+ input.branch ? `Branch: ${input.branch}` : '',
62
+ input.scope === 'staged'
63
+ ? 'The diff below is exactly what is staged for the next commit.'
64
+ : 'Nothing is staged yet; the diff below is every uncommitted change, staged or not.',
65
+ input.recentSubjects.length
66
+ ? 'RECENT COMMIT SUBJECTS (match their language and style):\n' +
67
+ input.recentSubjects.map((subject) => ` - ${subject}`).join('\n')
68
+ : '',
69
+ '',
70
+ COMMIT_MESSAGE_CONTRACT,
71
+ '',
72
+ 'DIFF',
73
+ '====',
74
+ body || '(no changes)',
75
+ ];
76
+ return sections.filter((section) => section !== '').join('\n');
77
+ }
78
+ /** Unwraps `--output-format json` and tolerates a fenced or plain-text reply. */
79
+ export function parseCommitMessage(raw) {
80
+ let text = raw.trim();
81
+ if (!text)
82
+ throw new Error('Claude Code returned an empty response.');
83
+ try {
84
+ const envelope = JSON.parse(text);
85
+ if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
86
+ if (envelope.is_error === true) {
87
+ throw new Error(String(envelope.result ?? 'Claude Code reported an error.'));
88
+ }
89
+ if (typeof envelope.result === 'string')
90
+ text = envelope.result.trim();
91
+ }
92
+ }
93
+ catch (error) {
94
+ // Not the CLI envelope; fall through and read the text as the message.
95
+ if (error?.message?.startsWith('Claude Code'))
96
+ throw error;
97
+ }
98
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
99
+ if (fenced)
100
+ text = fenced[1].trim();
101
+ const start = text.indexOf('{');
102
+ const end = text.lastIndexOf('}');
103
+ if (start !== -1 && end > start) {
104
+ try {
105
+ const message = JSON.parse(text.slice(start, end + 1));
106
+ const subject = oneLine(message?.subject);
107
+ if (subject)
108
+ return { subject, description: String(message?.description ?? '').trim() };
109
+ }
110
+ catch {
111
+ // Malformed JSON; fall through and read the text as the message.
112
+ }
113
+ }
114
+ // Plain text: the first line is the subject, the rest is the description.
115
+ const [first, ...rest] = text.split('\n');
116
+ const subject = oneLine(first);
117
+ if (!subject) {
118
+ throw new Error(`Claude Code did not return a commit message: ${text.slice(0, 300)}`);
119
+ }
120
+ return { subject, description: rest.join('\n').trim() };
121
+ }
122
+ /** Drafts a commit message by running the Claude Code CLI over the changes. */
123
+ export async function generateCommitMessage(options) {
124
+ const model = options.model?.trim() ||
125
+ options.config.commitMessageModel.trim() ||
126
+ DEFAULT_COMMIT_MESSAGE_MODEL;
127
+ const prompt = buildCommitMessagePrompt({
128
+ branch: options.branch,
129
+ scope: options.scope,
130
+ files: options.files,
131
+ recentSubjects: options.recentSubjects,
132
+ maxDiffChars: Math.min(options.config.maxDiffChars, COMMIT_MESSAGE_MAX_DIFF_CHARS),
133
+ });
134
+ const command = await resolveClaudeCommand(options.config.claudePath);
135
+ const args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])];
136
+ options.log?.(`AI commit message: drafting with ${command} (${model})`);
137
+ const runModel = options.runModel ?? runClaudeCli;
138
+ const raw = await runModel(prompt, {
139
+ command,
140
+ args,
141
+ cwd: options.cwd,
142
+ timeoutMs: Math.min(options.config.timeoutSeconds, COMMIT_MESSAGE_TIMEOUT_SECONDS) * 1000,
143
+ });
144
+ const { subject, description } = parseCommitMessage(raw);
145
+ return { subject, description, scope: options.scope, model };
146
+ }