@mcarvin/smart-diff 1.0.4 → 1.1.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.mjs CHANGED
@@ -35,7 +35,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
35
35
 
36
36
  function resolveLlmBaseUrl() {
37
37
  var _a, _b, _c;
38
- return (_b = (_a = process.env.LLM_BASE_URL) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : (_c = process.env.OPENAI_BASE_URL) === null || _c === void 0 ? void 0 : _c.trim();
38
+ return ((_b = (_a = process.env.LLM_BASE_URL) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : (_c = process.env.OPENAI_BASE_URL) === null || _c === void 0 ? void 0 : _c.trim());
39
39
  }
40
40
  function parseHeaderJsonObject(raw) {
41
41
  const trimmed = raw === null || raw === void 0 ? void 0 : raw.trim();
@@ -43,12 +43,14 @@ function parseHeaderJsonObject(raw) {
43
43
  return {};
44
44
  try {
45
45
  const parsed = JSON.parse(trimmed);
46
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
46
+ if (typeof parsed !== "object" ||
47
+ parsed === null ||
48
+ Array.isArray(parsed)) {
47
49
  return {};
48
50
  }
49
51
  const out = {};
50
52
  for (const [key, value] of Object.entries(parsed)) {
51
- if (typeof value === 'string' && value.length > 0) {
53
+ if (typeof value === "string" && value.length > 0) {
52
54
  out[key] = value;
53
55
  }
54
56
  }
@@ -65,7 +67,7 @@ function parseLlmDefaultHeadersFromEnv() {
65
67
  return Object.keys(merged).length > 0 ? merged : undefined;
66
68
  }
67
69
  function findAuthorizationHeaderName(headers) {
68
- return Object.keys(headers).find((k) => k.toLowerCase() === 'authorization');
70
+ return Object.keys(headers).find((k) => k.toLowerCase() === "authorization");
69
71
  }
70
72
  function stripBearerPrefix(value) {
71
73
  var _a;
@@ -108,7 +110,7 @@ function resolveOpenAiLikeClientInit() {
108
110
  var _a, _b, _c, _d, _e;
109
111
  const baseURL = resolveLlmBaseUrl();
110
112
  const mergedHeaders = (_a = parseLlmDefaultHeadersFromEnv()) !== null && _a !== void 0 ? _a : {};
111
- const envApiKey = (_e = (_c = (_b = process.env.LLM_API_KEY) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : (_d = process.env.OPENAI_API_KEY) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : '';
113
+ const envApiKey = (_e = (_c = (_b = process.env.LLM_API_KEY) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : (_d = process.env.OPENAI_API_KEY) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : "";
112
114
  let defaultHeaders;
113
115
  let apiKey = envApiKey;
114
116
  if (apiKey.length === 0) {
@@ -116,12 +118,16 @@ function resolveOpenAiLikeClientInit() {
116
118
  if (split.apiKeyFromAuthHeader) {
117
119
  apiKey = split.apiKeyFromAuthHeader;
118
120
  }
119
- defaultHeaders = Object.keys(split.defaultHeaders).length > 0 ? split.defaultHeaders : undefined;
121
+ defaultHeaders =
122
+ Object.keys(split.defaultHeaders).length > 0
123
+ ? split.defaultHeaders
124
+ : undefined;
120
125
  }
121
126
  else {
122
- defaultHeaders = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
127
+ defaultHeaders =
128
+ Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
123
129
  }
124
- return Object.assign(Object.assign({ apiKey: apiKey.length > 0 ? apiKey : 'unused' }, (baseURL ? { baseURL } : {})), (defaultHeaders ? { defaultHeaders } : {}));
130
+ return Object.assign(Object.assign({ apiKey: apiKey.length > 0 ? apiKey : "unused" }, (baseURL ? { baseURL } : {})), (defaultHeaders ? { defaultHeaders } : {}));
125
131
  }
126
132
  function createOpenAiLikeClient() {
127
133
  return __awaiter(this, void 0, void 0, function* () {
@@ -131,9 +137,22 @@ function createOpenAiLikeClient() {
131
137
  }
132
138
 
133
139
  const DEFAULT_LLM_MAX_DIFF_CHARS = 120000;
140
+ const DEFAULT_GIT_DIFF_SYSTEM_PROMPT = `You are a senior software engineer helping developers understand code and configuration changes from the git context they supplied.
141
+ You receive: commit subject lines (when available), changed file paths, and unified git patch(es)—either one range diff or concatenated per-commit patches, depending on how the diff was produced. Patches may be truncated mid-section with an explicit marker—do not infer changes beyond visible lines.
142
+ Explain what changed in terms of behavior, APIs, data, configuration, security, and operational risk. Tie claims to the patch when possible.
143
+ Produce a concise, developer-focused summary in Markdown.
144
+ Use sections that fit the change (for example: Highlights, Breaking or risky changes, API / contract changes, Data & schema, Configuration & infra, Security & auth, Tests & quality). Omit empty sections.
145
+ Group related changes; do not list every individual file. When multiple commits appear in the context, briefly separate notable themes by commit when helpful.
146
+ If the user message includes a Team line, use that exact team name in the summary title (for example: "## <Team> – Change summary" or similar).`;
147
+ const LLM_GATEWAY_REQUIRED_MESSAGE = "No LLM gateway configured. Set OPENAI_API_KEY or LLM_API_KEY, and/or LLM_BASE_URL or OPENAI_BASE_URL, " +
148
+ "and/or JSON in OPENAI_DEFAULT_HEADERS or LLM_DEFAULT_HEADERS. " +
149
+ "Alternatively pass openAiClientProvider to generateSummary or summarizeGitDiff.";
150
+
134
151
  function resolveLlmMaxDiffChars(cliOverride) {
135
152
  var _a;
136
- if (cliOverride !== undefined && Number.isFinite(cliOverride) && cliOverride > 0) {
153
+ if (cliOverride !== undefined &&
154
+ Number.isFinite(cliOverride) &&
155
+ cliOverride > 0) {
137
156
  return Math.trunc(cliOverride);
138
157
  }
139
158
  const raw = (_a = process.env.LLM_MAX_DIFF_CHARS) === null || _a === void 0 ? void 0 : _a.trim();
@@ -152,70 +171,75 @@ function truncateUnifiedDiffForLlm(diffText, maxChars) {
152
171
  const marker = `\n\n--- TRUNCATED: unified diff was ${diffText.length} characters; only the first ${maxChars} were sent. Narrow the ref range, adjust commit/path filters, or raise maxDiffChars / LLM_MAX_DIFF_CHARS only if your model context allows. ---\n`;
153
172
  return diffText.slice(0, maxChars) + marker;
154
173
  }
155
- const DEFAULT_GIT_DIFF_SYSTEM_PROMPT = `You are a senior software engineer helping developers understand code and configuration changes from the git context they supplied.
156
- You receive: commit subject lines (when available), changed file paths, and unified git patch(es)—either one range diff or concatenated per-commit patches, depending on how the diff was produced. Patches may be truncated mid-section with an explicit marker—do not infer changes beyond visible lines.
157
- Explain what changed in terms of behavior, APIs, data, configuration, security, and operational risk. Tie claims to the patch when possible.
158
- Produce a concise, developer-focused summary in Markdown.
159
- Use sections that fit the change (for example: Highlights, Breaking or risky changes, API / contract changes, Data & schema, Configuration & infra, Security & auth, Tests & quality). Omit empty sections.
160
- Group related changes; do not list every individual file. When multiple commits appear in the context, briefly separate notable themes by commit when helpful.
161
- If the user message includes a Team line, use that exact team name in the summary title (for example: "## <Team> – Change summary" or similar).`;
162
- const LLM_GATEWAY_REQUIRED_MESSAGE = 'No LLM gateway configured. Set OPENAI_API_KEY or LLM_API_KEY, and/or LLM_BASE_URL or OPENAI_BASE_URL, ' +
163
- 'and/or JSON in OPENAI_DEFAULT_HEADERS or LLM_DEFAULT_HEADERS. ' +
164
- 'Alternatively pass openAiClientProvider to generateSummary or summarizeGitDiff.';
165
- function generateSummary(diffText, fileNames, commits, flags, openAiClientProvider, diffSummary) {
174
+ function markdownDiffTruncationNotice(originalChars, maxChars) {
175
+ return `> **Truncated diff:** The unified diff was ${originalChars} characters; only the first ${maxChars} were sent to the model. The summary may not reflect the full change set. Narrow the ref range, adjust path filters, or raise \`maxDiffChars\` / \`LLM_MAX_DIFF_CHARS\`—often together with switching to a model whose context window can fit a larger prompt.\n\n`;
176
+ }
177
+ function generateSummary(input) {
166
178
  return __awaiter(this, void 0, void 0, function* () {
167
179
  var _a, _b;
180
+ const { diffText, fileNames, commits, flags, openAiClientProvider, diffSummary, } = input;
168
181
  if (!shouldUseLlmGateway() && openAiClientProvider === undefined) {
169
182
  throw new Error(LLM_GATEWAY_REQUIRED_MESSAGE);
170
183
  }
171
184
  const maxDiffChars = resolveLlmMaxDiffChars(flags.maxDiffChars);
185
+ const diffTruncated = diffText.length > maxDiffChars;
172
186
  const diffForLlm = truncateUnifiedDiffForLlm(diffText, maxDiffChars);
173
187
  const userContent = buildOpenAiUserContent(flags, commits, fileNames, diffForLlm, diffSummary);
174
- return callOpenAi(userContent, (_a = flags.model) !== null && _a !== void 0 ? _a : 'gpt-4o-mini', (_b = flags.systemPrompt) !== null && _b !== void 0 ? _b : DEFAULT_GIT_DIFF_SYSTEM_PROMPT, openAiClientProvider !== null && openAiClientProvider !== void 0 ? openAiClientProvider : (() => __awaiter(this, void 0, void 0, function* () { return createOpenAiLikeClient(); })));
188
+ const summary = yield callOpenAi(userContent, (_a = flags.model) !== null && _a !== void 0 ? _a : "gpt-4o-mini", (_b = flags.systemPrompt) !== null && _b !== void 0 ? _b : DEFAULT_GIT_DIFF_SYSTEM_PROMPT, openAiClientProvider !== null && openAiClientProvider !== void 0 ? openAiClientProvider : (() => __awaiter(this, void 0, void 0, function* () { return createOpenAiLikeClient(); })));
189
+ if (!diffTruncated) {
190
+ return summary;
191
+ }
192
+ return markdownDiffTruncationNotice(diffText.length, maxDiffChars) + summary;
175
193
  });
176
194
  }
177
195
  function formatRegexFilterLines(flags) {
178
196
  var _a, _b;
179
- const includes = ((_a = flags.commitMessageIncludeRegexes) !== null && _a !== void 0 ? _a : []).map((s) => s.trim()).filter(Boolean);
180
- const excludes = ((_b = flags.commitMessageExcludeRegexes) !== null && _b !== void 0 ? _b : []).map((s) => s.trim()).filter(Boolean);
197
+ const includes = ((_a = flags.commitMessageIncludeRegexes) !== null && _a !== void 0 ? _a : [])
198
+ .map((s) => s.trim())
199
+ .filter(Boolean);
200
+ const excludes = ((_b = flags.commitMessageExcludeRegexes) !== null && _b !== void 0 ? _b : [])
201
+ .map((s) => s.trim())
202
+ .filter(Boolean);
181
203
  const incLine = includes.length > 0
182
- ? `Commit message include regexes (OR): ${includes.map((r) => JSON.stringify(r)).join(', ')}\n`
183
- : '';
204
+ ? `Commit message include regexes (OR): ${includes.map((r) => JSON.stringify(r)).join(", ")}\n`
205
+ : "";
184
206
  const excLine = excludes.length > 0
185
- ? `Commit message exclude regexes: ${excludes.map((r) => JSON.stringify(r)).join(', ')}\n`
186
- : '';
207
+ ? `Commit message exclude regexes: ${excludes.map((r) => JSON.stringify(r)).join(", ")}\n`
208
+ : "";
187
209
  if (!incLine && !excLine) {
188
- return 'Commit message filters: none.\nGit context shape: single unified diff for the full ref range.\n';
210
+ return "Commit message filters: none.\nGit context shape: single unified diff for the full ref range.\n";
189
211
  }
190
212
  return (`${incLine}${excLine}` +
191
- 'Git context shape: concatenated per-commit unified patches for commits that pass the message filters.\n');
213
+ "Git context shape: concatenated per-commit unified patches for commits that pass the message filters.\n");
192
214
  }
193
215
  function buildOpenAiUserContent(flags, commits, fileNames, diffText, diffSummary) {
194
216
  var _a, _b;
195
217
  const from = flags.from;
196
- const to = (_a = flags.to) !== null && _a !== void 0 ? _a : 'HEAD';
218
+ const to = (_a = flags.to) !== null && _a !== void 0 ? _a : "HEAD";
197
219
  const team = (_b = flags.team) === null || _b === void 0 ? void 0 : _b.trim();
198
220
  const ts = new Date().toISOString();
199
- const teamLine = team ? `Team: ${team}\n` : '';
221
+ const teamLine = team ? `Team: ${team}\n` : "";
200
222
  const filterBlock = formatRegexFilterLines(flags);
201
223
  const commitBlock = commits.length > 0
202
- ? commits.map((c) => `- ${c.hash.slice(0, 7)} ${c.message.replace(/\r?\n/g, ' ')}`).join('\n')
203
- : '- (no commits in range after filtering)';
204
- const pathsBlock = fileNames.length > 0 ? fileNames.join('\n') : '(no paths in diff scope)';
224
+ ? commits
225
+ .map((c) => `- ${c.hash.slice(0, 7)} ${c.message.replace(/\r?\n/g, " ")}`)
226
+ .join("\n")
227
+ : "- (no commits in range after filtering)";
228
+ const pathsBlock = fileNames.length > 0 ? fileNames.join("\n") : "(no paths in diff scope)";
205
229
  const structuredDiffSection = diffSummary
206
230
  ? `=== Structured git context (JSON summary) ===\n${JSON.stringify(diffSummary, null, 2)}\n\n`
207
- : '';
231
+ : "";
208
232
  return (`${teamLine}` +
209
233
  `Date: ${ts}\n\n` +
210
234
  `Git refs: ${from}..${to}\n` +
211
235
  filterBlock +
212
- '\n' +
213
- '=== Included commits (subject lines) ===\n' +
236
+ "\n" +
237
+ "=== Included commits (subject lines) ===\n" +
214
238
  `${commitBlock}\n\n` +
215
- '=== Changed paths ===\n' +
239
+ "=== Changed paths ===\n" +
216
240
  `${pathsBlock}\n\n` +
217
241
  structuredDiffSection +
218
- '=== Git context (unified diff(s); patches may be truncated with an explicit marker) ===\n' +
242
+ "=== Git context (unified diff(s); patches may be truncated with an explicit marker) ===\n" +
219
243
  diffText);
220
244
  }
221
245
  function callOpenAi(userContent, model, systemPrompt, openAiClientProvider) {
@@ -229,11 +253,11 @@ function callOpenAi(userContent, model, systemPrompt, openAiClientProvider) {
229
253
  model,
230
254
  messages: [
231
255
  {
232
- role: 'system',
256
+ role: "system",
233
257
  content: systemPrompt,
234
258
  },
235
259
  {
236
- role: 'user',
260
+ role: "user",
237
261
  content: userContent,
238
262
  },
239
263
  ],
@@ -241,64 +265,25 @@ function callOpenAi(userContent, model, systemPrompt, openAiClientProvider) {
241
265
  max_tokens: maxTokens,
242
266
  });
243
267
  const typedResponse = response;
244
- const text = (_f = (_e = (_d = (_c = (_b = typedResponse.choices) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.message) === null || _d === void 0 ? void 0 : _d.content) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : '';
245
- return text.length > 0 ? text : 'No summary generated by OpenAI.';
268
+ const text = (_f = (_e = (_d = (_c = (_b = typedResponse.choices) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.message) === null || _d === void 0 ? void 0 : _d.content) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : "";
269
+ return text.length > 0 ? text : "No summary generated by OpenAI.";
246
270
  });
247
271
  }
248
272
 
249
- function createGitClient(cwd = process.cwd()) {
250
- return simpleGit(cwd);
251
- }
252
- function getCommits(git, from, to) {
253
- return __awaiter(this, void 0, void 0, function* () {
254
- const logResult = yield git.log({ from, to });
255
- return logResult.all;
256
- });
257
- }
258
- function compileRegex(pattern, label) {
259
- try {
260
- return new RegExp(pattern, 'i');
261
- }
262
- catch (_a) {
263
- throw new Error(`Invalid ${label} regular expression: ${JSON.stringify(pattern)}`);
264
- }
265
- }
266
- function commitMessagePassesFilters(message, includeRes, excludeRes) {
267
- for (const ex of excludeRes) {
268
- if (ex.test(message))
269
- return false;
270
- }
271
- if (includeRes.length > 0 && !includeRes.some((r) => r.test(message)))
272
- return false;
273
- return true;
274
- }
275
- function filterCommitsByMessageRegexes(commits, includePatterns, excludePatterns) {
276
- const includes = (includePatterns !== null && includePatterns !== void 0 ? includePatterns : []).map((p) => p.trim()).filter((p) => p.length > 0);
277
- const excludes = (excludePatterns !== null && excludePatterns !== void 0 ? excludePatterns : []).map((p) => p.trim()).filter((p) => p.length > 0);
278
- const includeRes = includes.map((p, i) => compileRegex(p, `commit message include pattern[${i}]`));
279
- const excludeRes = excludes.map((p, i) => compileRegex(p, `commit message exclude pattern[${i}]`));
280
- return commits.filter((c) => commitMessagePassesFilters(c.message, includeRes, excludeRes));
281
- }
282
- function getRepoRoot(git) {
283
- return __awaiter(this, void 0, void 0, function* () {
284
- const root = yield git.revparse(['--show-toplevel']);
285
- return root.trim();
286
- });
287
- }
288
273
  function normalizeRepoRelativePath(p) {
289
- const trimmed = p.trim().replace(/\\/g, '/');
290
- const noLeading = trimmed.replace(/^\/+/, '');
291
- const noTrailingSlash = noLeading.replace(/\/+$/, '');
292
- return noTrailingSlash.length > 0 ? noTrailingSlash : '.';
274
+ const trimmed = p.trim().replace(/\\/g, "/");
275
+ const noLeading = trimmed.replace(/^\/+/, "");
276
+ const noTrailingSlash = noLeading.replace(/\/+$/, "");
277
+ return noTrailingSlash.length > 0 ? noTrailingSlash : ".";
293
278
  }
294
279
  function assertPathUnderRepo(repoRoot, userPath) {
295
280
  const abs = resolve(repoRoot, userPath);
296
281
  const rel = relative(repoRoot, abs);
297
- if (rel === '..') {
282
+ if (rel === "..") {
298
283
  throw new Error(`Path escapes repository root: ${JSON.stringify(userPath)}`);
299
284
  }
300
285
  const segments = rel.split(/[/\\]/);
301
- if (segments.includes('..')) {
286
+ if (segments.includes("..")) {
302
287
  throw new Error(`Path escapes repository root: ${JSON.stringify(userPath)}`);
303
288
  }
304
289
  }
@@ -306,9 +291,13 @@ function buildDiffPathspecs(repoRoot, pathFilter) {
306
291
  var _a, _b, _c, _d;
307
292
  const includeRaw = (_b = (_a = pathFilter === null || pathFilter === void 0 ? void 0 : pathFilter.includeFolders) === null || _a === void 0 ? void 0 : _a.filter((p) => p.trim().length > 0)) !== null && _b !== void 0 ? _b : [];
308
293
  const excludeRaw = (_d = (_c = pathFilter === null || pathFilter === void 0 ? void 0 : pathFilter.excludeFolders) === null || _c === void 0 ? void 0 : _c.filter((p) => p.trim().length > 0)) !== null && _d !== void 0 ? _d : [];
309
- const includes = includeRaw.map(normalizeRepoRelativePath).filter((p) => p !== '.' && p !== '');
310
- const excludes = excludeRaw.map(normalizeRepoRelativePath).filter((p) => p !== '.' && p !== '');
311
- const toValidate = includes.length > 0 ? includes : ['.'];
294
+ const includes = includeRaw
295
+ .map(normalizeRepoRelativePath)
296
+ .filter((p) => p !== "." && p !== "");
297
+ const excludes = excludeRaw
298
+ .map(normalizeRepoRelativePath)
299
+ .filter((p) => p !== "." && p !== "");
300
+ const toValidate = includes.length > 0 ? includes : ["."];
312
301
  for (const inc of toValidate) {
313
302
  assertPathUnderRepo(repoRoot, inc);
314
303
  }
@@ -317,7 +306,7 @@ function buildDiffPathspecs(repoRoot, pathFilter) {
317
306
  }
318
307
  const specs = [];
319
308
  if (includes.length === 0) {
320
- specs.push('.');
309
+ specs.push(".");
321
310
  }
322
311
  else {
323
312
  for (const inc of includes) {
@@ -329,100 +318,73 @@ function buildDiffPathspecs(repoRoot, pathFilter) {
329
318
  }
330
319
  return specs;
331
320
  }
332
- function getDiffPathContext(git, pathFilter, repoRootOverride) {
333
- return __awaiter(this, void 0, void 0, function* () {
334
- const repoRoot = repoRootOverride !== null && repoRootOverride !== void 0 ? repoRootOverride : (yield getRepoRoot(git));
335
- const specs = buildDiffPathspecs(repoRoot, pathFilter);
336
- return { repoRoot, specs };
337
- });
338
- }
339
- function getDiff(git, from, to, commits, filterByCommits, pathFilter, repoRootOverride) {
340
- return __awaiter(this, void 0, void 0, function* () {
341
- const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
342
- if (!filterByCommits) {
343
- return git.diff([`${from}..${to}`, '--', ...specs]);
344
- }
345
- const patches = yield Promise.all(commits.map((c) => git.diff([`${c.hash}^!`, '--', ...specs])));
346
- return patches.filter(Boolean).join('\n');
347
- });
321
+
322
+ function compileRegex(pattern, label) {
323
+ try {
324
+ return new RegExp(pattern, "i");
325
+ }
326
+ catch (_a) {
327
+ throw new Error(`Invalid ${label} regular expression: ${JSON.stringify(pattern)}`);
328
+ }
348
329
  }
349
- function getDiffSummary(git, from, to, commits, filterByCommits, pathFilter, repoRootOverride) {
350
- return __awaiter(this, void 0, void 0, function* () {
351
- const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
352
- if (!filterByCommits) {
353
- const [numOutput, nameOutput] = yield Promise.all([
354
- git.diff(['--numstat', `${from}..${to}`, '--', ...specs]),
355
- git.diff(['--name-status', `${from}..${to}`, '--', ...specs]),
356
- ]);
357
- return buildDiffSummaryFromGitOutputs(nameOutput, numOutput);
358
- }
359
- const pairs = yield Promise.all(commits.map((c) => __awaiter(this, void 0, void 0, function* () {
360
- const range = `${c.hash}^!`;
361
- const [numOutput, nameOutput] = yield Promise.all([
362
- git.diff(['--numstat', range, '--', ...specs]),
363
- git.diff(['--name-status', range, '--', ...specs]),
364
- ]);
365
- return { numOutput, nameOutput };
366
- })));
367
- const nameJoined = pairs
368
- .map((p) => p.nameOutput)
369
- .filter(Boolean)
370
- .join('\n');
371
- const numJoined = pairs
372
- .map((p) => p.numOutput)
373
- .filter(Boolean)
374
- .join('\n');
375
- return buildDiffSummaryFromGitOutputs(nameJoined, numJoined);
376
- });
330
+ function commitMessagePassesFilters(message, includeRes, excludeRes) {
331
+ for (const ex of excludeRes) {
332
+ if (ex.test(message))
333
+ return false;
334
+ }
335
+ if (includeRes.length > 0 && !includeRes.some((r) => r.test(message)))
336
+ return false;
337
+ return true;
377
338
  }
378
- function getChangedFiles(git, from, to, commits, filterByCommits, pathFilter, repoRootOverride) {
379
- return __awaiter(this, void 0, void 0, function* () {
380
- const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
381
- if (!filterByCommits) {
382
- const output = yield git.diff(['--name-only', `${from}..${to}`, '--', ...specs]);
383
- return output
384
- .split(/\r?\n/)
385
- .map((f) => f.trim())
386
- .filter(Boolean);
387
- }
388
- const fileSet = new Set();
389
- yield Promise.all(commits.map((c) => __awaiter(this, void 0, void 0, function* () {
390
- const output = yield git.show(['--name-only', '--pretty=format:', c.hash, '--', ...specs]);
391
- output
392
- .split(/\r?\n/)
393
- .map((f) => f.trim())
394
- .filter(Boolean)
395
- .forEach((f) => fileSet.add(f));
396
- })));
397
- return Array.from(fileSet);
398
- });
339
+ function filterCommitsByMessageRegexes(commits, includePatterns, excludePatterns) {
340
+ const includes = (includePatterns !== null && includePatterns !== void 0 ? includePatterns : [])
341
+ .map((p) => p.trim())
342
+ .filter((p) => p.length > 0);
343
+ const excludes = (excludePatterns !== null && excludePatterns !== void 0 ? excludePatterns : [])
344
+ .map((p) => p.trim())
345
+ .filter((p) => p.length > 0);
346
+ const includeRes = includes.map((p, i) => compileRegex(p, `commit message include pattern[${i}]`));
347
+ const excludeRes = excludes.map((p, i) => compileRegex(p, `commit message exclude pattern[${i}]`));
348
+ return commits.filter((c) => commitMessagePassesFilters(c.message, includeRes, excludeRes));
399
349
  }
350
+
400
351
  const GIT_STATUS_BY_FIRST_CHAR = {
401
- A: 'added',
402
- D: 'deleted',
403
- R: 'renamed',
404
- C: 'copied',
405
- T: 'type-changed',
406
- M: 'modified',
352
+ A: "added",
353
+ D: "deleted",
354
+ R: "renamed",
355
+ C: "copied",
356
+ T: "type-changed",
357
+ M: "modified",
407
358
  };
408
359
  function mapGitStatus(statusCode) {
409
360
  var _a;
410
- return (_a = GIT_STATUS_BY_FIRST_CHAR[statusCode.charAt(0)]) !== null && _a !== void 0 ? _a : 'unknown';
361
+ return (_a = GIT_STATUS_BY_FIRST_CHAR[statusCode.charAt(0)]) !== null && _a !== void 0 ? _a : "unknown";
411
362
  }
412
363
  function mergeStatus(existing, next) {
413
364
  if (existing === next)
414
365
  return existing;
415
- const precedence = ['deleted', 'added', 'renamed', 'copied', 'type-changed', 'modified', 'unknown'];
416
- return precedence.indexOf(existing) <= precedence.indexOf(next) ? existing : next;
366
+ const precedence = [
367
+ "deleted",
368
+ "added",
369
+ "renamed",
370
+ "copied",
371
+ "type-changed",
372
+ "modified",
373
+ "unknown",
374
+ ];
375
+ return precedence.indexOf(existing) <= precedence.indexOf(next)
376
+ ? existing
377
+ : next;
417
378
  }
379
+
418
380
  function parseNameStatusLine(line) {
419
381
  var _a;
420
- const parts = line.split('\t');
382
+ const parts = line.split("\t");
421
383
  let entry = null;
422
384
  if (parts.length >= 2) {
423
- const statusToken = (_a = parts[0]) !== null && _a !== void 0 ? _a : '';
385
+ const statusToken = (_a = parts[0]) !== null && _a !== void 0 ? _a : "";
424
386
  const status = mapGitStatus(statusToken);
425
- const isRenameOrCopy = statusToken.startsWith('R') || statusToken.startsWith('C');
387
+ const isRenameOrCopy = statusToken.startsWith("R") || statusToken.startsWith("C");
426
388
  if (isRenameOrCopy && parts.length >= 3) {
427
389
  const oldPath = parts[1];
428
390
  const newPath = parts[2];
@@ -468,6 +430,7 @@ function mergeNameEntriesByPath(entries) {
468
430
  }
469
431
  return byPath;
470
432
  }
433
+
471
434
  function numStatPathToLookupKey(pathField) {
472
435
  const brace = /^(.*)\{(.+) => (.+)\}$/.exec(pathField);
473
436
  if (!brace) {
@@ -479,14 +442,14 @@ function numStatPathToLookupKey(pathField) {
479
442
  }
480
443
  function parseNumStatLine(line) {
481
444
  var _a, _b;
482
- const parts = line.split('\t');
445
+ const parts = line.split("\t");
483
446
  if (parts.length < 3)
484
447
  return null;
485
- const addStr = (_a = parts[0]) !== null && _a !== void 0 ? _a : '';
486
- const delStr = (_b = parts[1]) !== null && _b !== void 0 ? _b : '';
487
- const pathField = parts.slice(2).join('\t');
488
- const additions = addStr !== '-' ? Number.parseInt(addStr, 10) || 0 : 0;
489
- const deletions = delStr !== '-' ? Number.parseInt(delStr, 10) || 0 : 0;
448
+ const addStr = (_a = parts[0]) !== null && _a !== void 0 ? _a : "";
449
+ const delStr = (_b = parts[1]) !== null && _b !== void 0 ? _b : "";
450
+ const pathField = parts.slice(2).join("\t");
451
+ const additions = addStr !== "-" ? Number.parseInt(addStr, 10) || 0 : 0;
452
+ const deletions = delStr !== "-" ? Number.parseInt(delStr, 10) || 0 : 0;
490
453
  const key = numStatPathToLookupKey(pathField);
491
454
  return { key, additions, deletions };
492
455
  }
@@ -500,56 +463,35 @@ function accumulateNumStat(numStatOutput, into) {
500
463
  if (!parsed)
501
464
  continue;
502
465
  const prev = (_a = into.get(parsed.key)) !== null && _a !== void 0 ? _a : { additions: 0, deletions: 0 };
503
- into.set(parsed.key, { additions: prev.additions + parsed.additions, deletions: prev.deletions + parsed.deletions });
504
- }
505
- }
506
- const STATUS_TO_SYNTHETIC_PREFIX = {
507
- added: 'A',
508
- deleted: 'D',
509
- renamed: 'R100',
510
- copied: 'C100',
511
- 'type-changed': 'T',
512
- modified: 'M',
513
- unknown: 'X',
514
- };
515
- function diffStatusToSyntheticPrefix(status) {
516
- return STATUS_TO_SYNTHETIC_PREFIX[status];
517
- }
518
- function buildSyntheticDiffLine(meta, counts) {
519
- const prefix = diffStatusToSyntheticPrefix(meta.status);
520
- if (meta.oldPath) {
521
- return `${prefix}\t${counts.additions}\t${counts.deletions}\t${meta.oldPath}\t${meta.path}`;
522
- }
523
- return `${prefix}\t${counts.additions}\t${counts.deletions}\t${meta.path}`;
524
- }
525
- function buildDiffSummaryFromGitOutputs(nameStatusOutput, numStatOutput) {
526
- var _a;
527
- const numMap = new Map();
528
- accumulateNumStat(numStatOutput, numMap);
529
- const mergedName = mergeNameEntriesByPath(parseNameStatusLines(nameStatusOutput));
530
- const syntheticLines = [];
531
- for (const [path, meta] of mergedName) {
532
- const counts = (_a = numMap.get(path)) !== null && _a !== void 0 ? _a : { additions: 0, deletions: 0 };
533
- syntheticLines.push(buildSyntheticDiffLine(meta, counts));
466
+ into.set(parsed.key, {
467
+ additions: prev.additions + parsed.additions,
468
+ deletions: prev.deletions + parsed.deletions,
469
+ });
534
470
  }
535
- return parseDiffSummary(syntheticLines.join('\n'));
536
471
  }
472
+
537
473
  function parseTabDiffSummaryLine(line) {
538
474
  var _a;
539
- const parts = line.split('\t');
475
+ const parts = line.split("\t");
540
476
  if (parts.length < 3)
541
477
  return null;
542
- const statusToken = (_a = parts.shift()) !== null && _a !== void 0 ? _a : '';
478
+ const statusToken = (_a = parts.shift()) !== null && _a !== void 0 ? _a : "";
543
479
  const status = mapGitStatus(statusToken);
544
480
  const add0 = parts[0];
545
481
  const del0 = parts[1];
546
- const additions = add0 && add0 !== '-' ? Number.parseInt(add0, 10) || 0 : 0;
547
- const deletions = del0 && del0 !== '-' ? Number.parseInt(del0, 10) || 0 : 0;
482
+ const additions = add0 && add0 !== "-" ? Number.parseInt(add0, 10) || 0 : 0;
483
+ const deletions = del0 && del0 !== "-" ? Number.parseInt(del0, 10) || 0 : 0;
548
484
  if (parts.length === 3) {
549
485
  return { status, additions, deletions, newPath: parts[2] };
550
486
  }
551
487
  if (parts.length === 4) {
552
- return { status, additions, deletions, oldPath: parts[2], newPath: parts[3] };
488
+ return {
489
+ status,
490
+ additions,
491
+ deletions,
492
+ oldPath: parts[2],
493
+ newPath: parts[3],
494
+ };
553
495
  }
554
496
  return null;
555
497
  }
@@ -595,11 +537,142 @@ function parseDiffSummary(diffOutput) {
595
537
  };
596
538
  }
597
539
 
540
+ const STATUS_TO_SYNTHETIC_PREFIX = {
541
+ added: "A",
542
+ deleted: "D",
543
+ renamed: "R100",
544
+ copied: "C100",
545
+ "type-changed": "T",
546
+ modified: "M",
547
+ unknown: "X",
548
+ };
549
+ function diffStatusToSyntheticPrefix(status) {
550
+ return STATUS_TO_SYNTHETIC_PREFIX[status];
551
+ }
552
+ function buildSyntheticDiffLine(meta, counts) {
553
+ const prefix = diffStatusToSyntheticPrefix(meta.status);
554
+ if (meta.oldPath) {
555
+ return `${prefix}\t${counts.additions}\t${counts.deletions}\t${meta.oldPath}\t${meta.path}`;
556
+ }
557
+ return `${prefix}\t${counts.additions}\t${counts.deletions}\t${meta.path}`;
558
+ }
559
+ function buildDiffSummaryFromGitOutputs(nameStatusOutput, numStatOutput) {
560
+ var _a;
561
+ const numMap = new Map();
562
+ accumulateNumStat(numStatOutput, numMap);
563
+ const mergedName = mergeNameEntriesByPath(parseNameStatusLines(nameStatusOutput));
564
+ const syntheticLines = [];
565
+ for (const [path, meta] of mergedName) {
566
+ const counts = (_a = numMap.get(path)) !== null && _a !== void 0 ? _a : { additions: 0, deletions: 0 };
567
+ syntheticLines.push(buildSyntheticDiffLine(meta, counts));
568
+ }
569
+ return parseDiffSummary(syntheticLines.join("\n"));
570
+ }
571
+
572
+ function createGitClient(cwd = process.cwd()) {
573
+ return simpleGit(cwd);
574
+ }
575
+ function getCommits(git, from, to) {
576
+ return __awaiter(this, void 0, void 0, function* () {
577
+ const logResult = yield git.log({ from, to });
578
+ return logResult.all;
579
+ });
580
+ }
581
+ function getRepoRoot(git) {
582
+ return __awaiter(this, void 0, void 0, function* () {
583
+ const root = yield git.revparse(["--show-toplevel"]);
584
+ return root.trim();
585
+ });
586
+ }
587
+ function getDiffPathContext(git, pathFilter, repoRootOverride) {
588
+ return __awaiter(this, void 0, void 0, function* () {
589
+ const repoRoot = repoRootOverride !== null && repoRootOverride !== void 0 ? repoRootOverride : (yield getRepoRoot(git));
590
+ const specs = buildDiffPathspecs(repoRoot, pathFilter);
591
+ return { repoRoot, specs };
592
+ });
593
+ }
594
+ function getDiff(git, query) {
595
+ return __awaiter(this, void 0, void 0, function* () {
596
+ const { from, to, commits, filterByCommits, pathFilter, repoRootOverride } = query;
597
+ const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
598
+ if (!filterByCommits) {
599
+ return git.diff([`${from}..${to}`, "--", ...specs]);
600
+ }
601
+ const patches = yield Promise.all(commits.map((c) => git.diff([`${c.hash}^!`, "--", ...specs])));
602
+ return patches.filter(Boolean).join("\n");
603
+ });
604
+ }
605
+ function getDiffSummary(git, query) {
606
+ return __awaiter(this, void 0, void 0, function* () {
607
+ const { from, to, commits, filterByCommits, pathFilter, repoRootOverride } = query;
608
+ const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
609
+ if (!filterByCommits) {
610
+ const [numOutput, nameOutput] = yield Promise.all([
611
+ git.diff(["--numstat", `${from}..${to}`, "--", ...specs]),
612
+ git.diff(["--name-status", `${from}..${to}`, "--", ...specs]),
613
+ ]);
614
+ return buildDiffSummaryFromGitOutputs(nameOutput, numOutput);
615
+ }
616
+ const pairs = yield Promise.all(commits.map((c) => __awaiter(this, void 0, void 0, function* () {
617
+ const range = `${c.hash}^!`;
618
+ const [numOutput, nameOutput] = yield Promise.all([
619
+ git.diff(["--numstat", range, "--", ...specs]),
620
+ git.diff(["--name-status", range, "--", ...specs]),
621
+ ]);
622
+ return { numOutput, nameOutput };
623
+ })));
624
+ const nameJoined = pairs
625
+ .map((p) => p.nameOutput)
626
+ .filter(Boolean)
627
+ .join("\n");
628
+ const numJoined = pairs
629
+ .map((p) => p.numOutput)
630
+ .filter(Boolean)
631
+ .join("\n");
632
+ return buildDiffSummaryFromGitOutputs(nameJoined, numJoined);
633
+ });
634
+ }
635
+ function getChangedFiles(git, query) {
636
+ return __awaiter(this, void 0, void 0, function* () {
637
+ const { from, to, commits, filterByCommits, pathFilter, repoRootOverride } = query;
638
+ const { specs } = yield getDiffPathContext(git, pathFilter, repoRootOverride);
639
+ if (!filterByCommits) {
640
+ const output = yield git.diff([
641
+ "--name-only",
642
+ `${from}..${to}`,
643
+ "--",
644
+ ...specs,
645
+ ]);
646
+ return output
647
+ .split(/\r?\n/)
648
+ .map((f) => f.trim())
649
+ .filter(Boolean);
650
+ }
651
+ const fileSet = new Set();
652
+ yield Promise.all(commits.map((c) => __awaiter(this, void 0, void 0, function* () {
653
+ const output = yield git.show([
654
+ "--name-only",
655
+ "--pretty=format:",
656
+ c.hash,
657
+ "--",
658
+ ...specs,
659
+ ]);
660
+ output
661
+ .split(/\r?\n/)
662
+ .map((f) => f.trim())
663
+ .filter(Boolean)
664
+ .forEach((f) => fileSet.add(f));
665
+ })));
666
+ return Array.from(fileSet);
667
+ });
668
+ }
669
+
598
670
  function hasNonEmptyTrimmed(arr) {
599
671
  return (arr !== null && arr !== void 0 ? arr : []).some((s) => s.trim().length > 0);
600
672
  }
601
673
  function shouldFilterByCommits(allCommits, filtered, opts) {
602
- if (hasNonEmptyTrimmed(opts.commitMessageIncludeRegexes) || hasNonEmptyTrimmed(opts.commitMessageExcludeRegexes)) {
674
+ if (hasNonEmptyTrimmed(opts.commitMessageIncludeRegexes) ||
675
+ hasNonEmptyTrimmed(opts.commitMessageExcludeRegexes)) {
603
676
  return true;
604
677
  }
605
678
  return filtered.length !== allCommits.length;
@@ -609,8 +682,9 @@ function summarizeGitDiff(options) {
609
682
  var _a, _b;
610
683
  const git = (_a = options.git) !== null && _a !== void 0 ? _a : createGitClient(options.cwd);
611
684
  const from = options.from;
612
- const to = (_b = options.to) !== null && _b !== void 0 ? _b : 'HEAD';
613
- const pathFilter = hasNonEmptyTrimmed(options.includeFolders) || hasNonEmptyTrimmed(options.excludeFolders)
685
+ const to = (_b = options.to) !== null && _b !== void 0 ? _b : "HEAD";
686
+ const pathFilter = hasNonEmptyTrimmed(options.includeFolders) ||
687
+ hasNonEmptyTrimmed(options.excludeFolders)
614
688
  ? {
615
689
  includeFolders: options.includeFolders,
616
690
  excludeFolders: options.excludeFolders,
@@ -619,10 +693,17 @@ function summarizeGitDiff(options) {
619
693
  const allCommits = yield getCommits(git, from, to);
620
694
  const filteredCommits = filterCommitsByMessageRegexes(allCommits, options.commitMessageIncludeRegexes, options.commitMessageExcludeRegexes);
621
695
  const filterByCommits = shouldFilterByCommits(allCommits, filteredCommits, options);
696
+ const rangeQuery = {
697
+ from,
698
+ to,
699
+ commits: filteredCommits,
700
+ filterByCommits,
701
+ pathFilter,
702
+ };
622
703
  const [diffText, fileNames, diffSummary] = yield Promise.all([
623
- getDiff(git, from, to, filteredCommits, filterByCommits, pathFilter),
624
- getChangedFiles(git, from, to, filteredCommits, filterByCommits, pathFilter),
625
- getDiffSummary(git, from, to, filteredCommits, filterByCommits, pathFilter),
704
+ getDiff(git, rangeQuery),
705
+ getChangedFiles(git, rangeQuery),
706
+ getDiffSummary(git, rangeQuery),
626
707
  ]);
627
708
  const summarizeFlags = {
628
709
  from,
@@ -634,7 +715,14 @@ function summarizeGitDiff(options) {
634
715
  commitMessageIncludeRegexes: options.commitMessageIncludeRegexes,
635
716
  commitMessageExcludeRegexes: options.commitMessageExcludeRegexes,
636
717
  };
637
- return generateSummary(diffText, fileNames, filteredCommits, summarizeFlags, options.openAiClientProvider, diffSummary);
718
+ return generateSummary({
719
+ diffText,
720
+ fileNames,
721
+ commits: filteredCommits,
722
+ flags: summarizeFlags,
723
+ openAiClientProvider: options.openAiClientProvider,
724
+ diffSummary,
725
+ });
638
726
  });
639
727
  }
640
728