@rethunk/mcp-multi-root-git 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ import { matchesGlob } from "node:path";
2
+ import { z } from "zod";
3
+ import { gitTopLevel, isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Constants
9
+ // ---------------------------------------------------------------------------
10
+ const DEFAULT_EXCLUDE_PATTERNS = [
11
+ "*.lock",
12
+ "*.lockb",
13
+ "bun.lock",
14
+ "package-lock.json",
15
+ "yarn.lock",
16
+ "pnpm-lock.yaml",
17
+ "*.min.js",
18
+ "*.min.css",
19
+ "vendor/**",
20
+ "node_modules/**",
21
+ "dist/**",
22
+ ];
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Parse `git diff --stat` output into per-file stats.
28
+ * Format: " path/to/file | N ++++---"
29
+ * Final line: " N files changed, N insertions(+), N deletions(-)"
30
+ */
31
+ function parseStatOutput(stat) {
32
+ const result = new Map();
33
+ for (const line of stat.split("\n")) {
34
+ // Skip the summary line and blank lines
35
+ if (!line.includes("|"))
36
+ continue;
37
+ const pipeIdx = line.indexOf("|");
38
+ const filePart = line.slice(0, pipeIdx).trim();
39
+ const statPart = line.slice(pipeIdx + 1).trim();
40
+ // statPart looks like "5 ++---" or "3 +++", count + and -
41
+ const additions = (statPart.match(/\+/g) ?? []).length;
42
+ const deletions = (statPart.match(/-/g) ?? []).length;
43
+ result.set(filePart, { additions, deletions });
44
+ }
45
+ return result;
46
+ }
47
+ /**
48
+ * Parse `git diff` output into per-file chunks.
49
+ * Splits on "diff --git a/..." lines.
50
+ */
51
+ function parseDiffOutput(diff) {
52
+ const chunks = [];
53
+ // Each file section starts with "diff --git"
54
+ const parts = diff.split(/(?=^diff --git )/m);
55
+ for (const part of parts) {
56
+ if (!part.startsWith("diff --git "))
57
+ continue;
58
+ const firstNewline = part.indexOf("\n");
59
+ const header = firstNewline >= 0 ? part.slice(0, firstNewline) : part;
60
+ const body = firstNewline >= 0 ? part.slice(firstNewline + 1) : "";
61
+ chunks.push({ header, body });
62
+ }
63
+ return chunks;
64
+ }
65
+ /**
66
+ * Extract file paths and status from a diff chunk header + body.
67
+ * Header: "diff --git a/old b/new"
68
+ * Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
69
+ */
70
+ function extractFileInfo(header, body) {
71
+ // Parse "diff --git a/X b/Y"
72
+ const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
73
+ const aPath = headerMatch?.[1] ?? "";
74
+ const bPath = headerMatch?.[2] ?? aPath;
75
+ let status = "modified";
76
+ let oldPath;
77
+ if (/^new file mode/m.test(body)) {
78
+ status = "added";
79
+ }
80
+ else if (/^deleted file mode/m.test(body)) {
81
+ status = "deleted";
82
+ }
83
+ else if (/^rename from /m.test(body)) {
84
+ status = "renamed";
85
+ const fromMatch = /^rename from (.+)$/m.exec(body);
86
+ oldPath = fromMatch?.[1];
87
+ }
88
+ const path = status === "deleted" ? aPath : bPath;
89
+ return { path, oldPath, status };
90
+ }
91
+ /**
92
+ * Truncate diff body to at most `maxLines` lines (counting only hunk content lines).
93
+ * Returns { text, truncated }.
94
+ */
95
+ function truncateDiffBody(body, maxLines) {
96
+ const lines = body.split("\n");
97
+ if (lines.length <= maxLines) {
98
+ return { text: body, truncated: false };
99
+ }
100
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
101
+ }
102
+ /** Check whether a file path matches any of the given glob patterns. */
103
+ function matchesAnyPattern(filePath, patterns) {
104
+ const normalized = filePath.replace(/\\/g, "/");
105
+ for (const pattern of patterns) {
106
+ if (matchesGlob(normalized, pattern))
107
+ return true;
108
+ // Also match just the basename against simple patterns (e.g. "*.lock")
109
+ const basename = normalized.split("/").at(-1) ?? normalized;
110
+ if (matchesGlob(basename, pattern))
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ /** Build the diff args array from the `range` parameter. */
116
+ function buildDiffArgs(range) {
117
+ if (range === undefined || range === "") {
118
+ return { ok: true, args: [] };
119
+ }
120
+ const normalized = range.trim().toLowerCase();
121
+ if (normalized === "staged" || normalized === "cached") {
122
+ return { ok: true, args: ["--cached"] };
123
+ }
124
+ if (normalized === "head") {
125
+ return { ok: true, args: ["HEAD"] };
126
+ }
127
+ // Range like "A..B", "A...B", or a single ref
128
+ // Split on ".." or "..." separators to validate each token
129
+ const separatorMatch = /^(.+?)(\.{2,3})(.+)$/.exec(range.trim());
130
+ if (separatorMatch) {
131
+ const [, left, sep, right] = separatorMatch;
132
+ if (!isSafeGitUpstreamToken(left ?? "") || !isSafeGitUpstreamToken(right ?? "")) {
133
+ return { ok: false, error: `unsafe_range_token: ${range}` };
134
+ }
135
+ return { ok: true, args: [`${left}${sep}${right}`] };
136
+ }
137
+ // Single ref
138
+ if (!isSafeGitUpstreamToken(range.trim())) {
139
+ return { ok: false, error: `unsafe_range_token: ${range}` };
140
+ }
141
+ return { ok: true, args: [range.trim()] };
142
+ }
143
+ /** Human-readable label for the range. */
144
+ function rangeLabel(range, diffArgs) {
145
+ if (!range || range === "")
146
+ return "unstaged changes";
147
+ if (diffArgs[0] === "--cached")
148
+ return "staged changes";
149
+ return range;
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Tool registration
153
+ // ---------------------------------------------------------------------------
154
+ export function registerGitDiffSummaryTool(server) {
155
+ server.addTool({
156
+ name: "git_diff_summary",
157
+ description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
158
+ "truncated to configurable line limits, with noise files (lock files, dist, etc.) excluded by default. " +
159
+ "Use `range` to target staged, HEAD, or any revision range. See docs/mcp-tools.md.",
160
+ annotations: {
161
+ readOnlyHint: true,
162
+ },
163
+ parameters: WorkspacePickSchema.extend({
164
+ range: z
165
+ .string()
166
+ .optional()
167
+ .describe('Diff range. Examples: "staged", "HEAD~3..HEAD", "main...feature". ' +
168
+ "Default: unstaged changes."),
169
+ fileFilter: z
170
+ .string()
171
+ .optional()
172
+ .describe('Glob pattern to restrict output to matching files, e.g. "*.ts", "src/**".'),
173
+ maxLinesPerFile: z
174
+ .number()
175
+ .int()
176
+ .min(1)
177
+ .max(2000)
178
+ .optional()
179
+ .default(50)
180
+ .describe("Max diff lines to include per file. Default: 50."),
181
+ maxFiles: z
182
+ .number()
183
+ .int()
184
+ .min(1)
185
+ .max(500)
186
+ .optional()
187
+ .default(30)
188
+ .describe("Max files to include in output. Default: 30."),
189
+ excludePatterns: z
190
+ .array(z.string())
191
+ .optional()
192
+ .describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
193
+ }),
194
+ execute: async (args) => {
195
+ // --- Standard prelude ---
196
+ const pre = requireGitAndRoots(server, args, undefined);
197
+ if (!pre.ok)
198
+ return jsonRespond(pre.error);
199
+ const rootInput = pre.roots[0];
200
+ if (!rootInput)
201
+ return jsonRespond({ error: "no_workspace_root" });
202
+ const gitTop = gitTopLevel(rootInput);
203
+ if (!gitTop) {
204
+ return jsonRespond({ error: "not_a_git_repository", path: rootInput });
205
+ }
206
+ // --- Build git diff args ---
207
+ const diffArgsResult = buildDiffArgs(args.range);
208
+ if (!diffArgsResult.ok) {
209
+ return jsonRespond({ error: diffArgsResult.error });
210
+ }
211
+ const diffArgs = diffArgsResult.args;
212
+ // --- Run git diff --stat ---
213
+ const statResult = await spawnGitAsync(gitTop, ["diff", "--stat", ...diffArgs]);
214
+ if (!statResult.ok) {
215
+ return jsonRespond({
216
+ error: "git_diff_failed",
217
+ detail: (statResult.stderr || statResult.stdout).trim(),
218
+ });
219
+ }
220
+ const statMap = parseStatOutput(statResult.stdout);
221
+ // --- Run git diff ---
222
+ const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
223
+ if (!diffResult.ok) {
224
+ return jsonRespond({
225
+ error: "git_diff_failed",
226
+ detail: (diffResult.stderr || diffResult.stdout).trim(),
227
+ });
228
+ }
229
+ // --- Parse diff chunks ---
230
+ const chunks = parseDiffOutput(diffResult.stdout);
231
+ const totalFiles = chunks.length;
232
+ // --- Apply excludePatterns and fileFilter ---
233
+ const excludePatterns = args.excludePatterns !== undefined ? args.excludePatterns : DEFAULT_EXCLUDE_PATTERNS;
234
+ const excludedFiles = [];
235
+ const includedChunks = [];
236
+ for (const chunk of chunks) {
237
+ const { path: filePath } = extractFileInfo(chunk.header, chunk.body);
238
+ if (matchesAnyPattern(filePath, excludePatterns)) {
239
+ excludedFiles.push(filePath);
240
+ continue;
241
+ }
242
+ if (args.fileFilter && !matchesAnyPattern(filePath, [args.fileFilter])) {
243
+ continue;
244
+ }
245
+ includedChunks.push(chunk);
246
+ }
247
+ // --- Truncate to maxFiles ---
248
+ const maxFiles = args.maxFiles ?? 30;
249
+ const maxLinesPerFile = args.maxLinesPerFile ?? 50;
250
+ const truncatedFileCount = includedChunks.length > maxFiles ? includedChunks.length - maxFiles : 0;
251
+ const processedChunks = includedChunks.slice(0, maxFiles);
252
+ // --- Build FileDiff entries ---
253
+ let totalAdditions = 0;
254
+ let totalDeletions = 0;
255
+ const files = [];
256
+ for (const chunk of processedChunks) {
257
+ const { path: filePath, oldPath, status } = extractFileInfo(chunk.header, chunk.body);
258
+ const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
259
+ totalAdditions += stat.additions;
260
+ totalDeletions += stat.deletions;
261
+ const { text: diffText, truncated } = truncateDiffBody(chunk.body, maxLinesPerFile);
262
+ files.push({
263
+ path: filePath,
264
+ status,
265
+ additions: stat.additions,
266
+ deletions: stat.deletions,
267
+ ...spreadDefined("oldPath", oldPath),
268
+ truncated,
269
+ diff: diffText,
270
+ });
271
+ }
272
+ const rangeStr = rangeLabel(args.range, diffArgs);
273
+ const summary = {
274
+ range: rangeStr,
275
+ totalFiles,
276
+ totalAdditions,
277
+ totalDeletions,
278
+ files,
279
+ ...spreadWhen(truncatedFileCount > 0, { truncatedFiles: truncatedFileCount }),
280
+ ...spreadWhen(excludedFiles.length > 0, { excludedFiles }),
281
+ };
282
+ // --- Format output ---
283
+ if (args.format === "json") {
284
+ return jsonRespond(summary);
285
+ }
286
+ // --- Markdown output ---
287
+ const lines = [];
288
+ lines.push(`# Diff: ${rangeStr}`, "");
289
+ // Summary line
290
+ const fileWord = totalFiles === 1 ? "file" : "files";
291
+ let summaryLine = `**${totalFiles} ${fileWord} changed** (+${totalAdditions} \u2212${totalDeletions})`;
292
+ if (excludedFiles.length > 0) {
293
+ const excWord = excludedFiles.length === 1 ? "file" : "files";
294
+ summaryLine += `, ${excludedFiles.length} ${excWord} excluded (${excludedFiles.join(", ")})`;
295
+ }
296
+ if (truncatedFileCount > 0) {
297
+ summaryLine += `, ${truncatedFileCount} more file(s) omitted (maxFiles=${maxFiles})`;
298
+ }
299
+ lines.push(summaryLine, "");
300
+ for (const file of files) {
301
+ // Section header
302
+ const statusTag = file.status !== "modified" ? `, ${file.status}` : "";
303
+ const renameTag = file.oldPath ? ` (from ${file.oldPath})` : "";
304
+ lines.push(`## ${file.path}${renameTag} (+${file.additions} \u2212${file.deletions}${statusTag})`);
305
+ if (file.diff) {
306
+ lines.push("```diff", file.diff.trimEnd(), "```");
307
+ }
308
+ else {
309
+ lines.push("_(no diff content)_");
310
+ }
311
+ if (file.truncated) {
312
+ lines.push(`_(diff truncated at ${maxLinesPerFile} lines)_`);
313
+ }
314
+ lines.push("");
315
+ }
316
+ return lines.join("\n");
317
+ },
318
+ });
319
+ }
@@ -0,0 +1,276 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
3
+ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Constants
9
+ // ---------------------------------------------------------------------------
10
+ const MAX_COMMITS_HARD_CAP = 500;
11
+ const DEFAULT_MAX_COMMITS = 50;
12
+ const DEFAULT_SINCE = "7.days";
13
+ // Field separator written by git into stdout — we use git's %x01 (SOH) escape.
14
+ // The format string itself is safe ASCII; git emits the byte.
15
+ const FIELD_SEP_OUT = "\x01"; // what git outputs (SOH)
16
+ const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-START marker
17
+ // git log --pretty tformat: sha7, shaFull, subject, author, email, ISO date, relative date.
18
+ // %x02 is placed at the START of each record (tformat adds \n as terminator after each).
19
+ // Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
20
+ // each structured as: <fields>\x01\n\n <shortstat text>\n
21
+ // Fields are separated by %x01; the trailing \x01 before \n leaves one empty last field (ignored).
22
+ const PRETTY_FORMAT = "%x02%h%x01%H%x01%s%x01%aN%x01%aE%x01%aI%x01%ar%x01";
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Parse a shortstat line like "3 files changed, 12 insertions(+), 5 deletions(-)"
28
+ * Returns undefined when the line doesn't match (e.g. empty diff).
29
+ */
30
+ function parseShortstat(line) {
31
+ const m = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(line);
32
+ if (!m)
33
+ return undefined;
34
+ return {
35
+ filesChanged: parseInt(m[1] ?? "0", 10),
36
+ insertions: parseInt(m[2] ?? "0", 10),
37
+ deletions: parseInt(m[3] ?? "0", 10),
38
+ };
39
+ }
40
+ /**
41
+ * Fetch the current branch name (or detached HEAD fallback).
42
+ */
43
+ async function gitCurrentBranch(cwd, branchArg) {
44
+ if (branchArg?.trim())
45
+ return branchArg.trim();
46
+ const r = await spawnGitAsync(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
47
+ if (r.ok)
48
+ return r.stdout.trim();
49
+ return "HEAD";
50
+ }
51
+ /**
52
+ * Run git log for a single repo root and return structured data.
53
+ */
54
+ async function runGitLog(opts) {
55
+ const { top, since, paths, grep, author, maxCommits, branch } = opts;
56
+ // Resolve branch first (needed for output metadata).
57
+ const resolvedBranch = await gitCurrentBranch(top, branch);
58
+ // Fetch one extra commit to detect truncation.
59
+ const fetchLimit = maxCommits + 1;
60
+ const logArgs = [
61
+ "log",
62
+ `--pretty=tformat:${PRETTY_FORMAT}`,
63
+ "--shortstat",
64
+ `-n`,
65
+ String(fetchLimit),
66
+ `--since=${since}`,
67
+ ];
68
+ if (branch?.trim()) {
69
+ logArgs.push(branch.trim());
70
+ }
71
+ if (grep?.trim()) {
72
+ logArgs.push(`--grep=${grep.trim()}`, "-i");
73
+ }
74
+ if (author?.trim()) {
75
+ logArgs.push(`--author=${author.trim()}`);
76
+ }
77
+ if (paths.length > 0) {
78
+ logArgs.push("--", ...paths);
79
+ }
80
+ const r = await spawnGitAsync(top, logArgs);
81
+ if (!r.ok) {
82
+ return {
83
+ error: "git_log_failed",
84
+ path: top,
85
+ };
86
+ }
87
+ // Parse output.
88
+ // git log --pretty=tformat:%x02<fields>%x01 --shortstat emits, per commit:
89
+ // \x02<fields separated by SOH>\x01\n\n <shortstat line>\n
90
+ // The \x02 is a record-START marker. Splitting on \x02 gives an empty first chunk
91
+ // followed by one chunk per commit. Each chunk is:
92
+ // <fields>\x01\n\n <shortstat>\n
93
+ const raw = r.stdout;
94
+ const recordChunks = raw.split(RECORD_SEP_OUT).slice(1); // drop leading empty
95
+ const allCommits = [];
96
+ for (const chunk of recordChunks) {
97
+ if (!chunk.trim())
98
+ continue;
99
+ // Fields before the first newline; shortstat (if any) follows blank line.
100
+ const newlineIdx = chunk.indexOf("\n");
101
+ const fieldsPart = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk;
102
+ const statPart = newlineIdx >= 0 ? chunk.slice(newlineIdx + 1) : "";
103
+ const fields = fieldsPart.split(FIELD_SEP_OUT);
104
+ const [sha7, shaFull, subject, authorName, email, date, ageRelative] = fields;
105
+ if (!sha7 || !shaFull)
106
+ continue;
107
+ const stat = parseShortstat(statPart);
108
+ const commit = {
109
+ sha7: sha7.trim(),
110
+ shaFull: shaFull.trim(),
111
+ subject: subject?.trim() ?? "",
112
+ author: authorName?.trim() ?? "",
113
+ email: email?.trim() ?? "",
114
+ date: date?.trim() ?? "",
115
+ ageRelative: ageRelative?.trim() ?? "",
116
+ ...spreadDefined("filesChanged", stat?.filesChanged),
117
+ ...spreadDefined("insertions", stat?.insertions),
118
+ ...spreadDefined("deletions", stat?.deletions),
119
+ };
120
+ allCommits.push(commit);
121
+ }
122
+ const truncated = allCommits.length > maxCommits;
123
+ const commits = truncated ? allCommits.slice(0, maxCommits) : allCommits;
124
+ const omittedCount = truncated ? allCommits.length - maxCommits : 0;
125
+ return {
126
+ workspace_root: top,
127
+ repo: basename(top),
128
+ branch: resolvedBranch,
129
+ commits,
130
+ truncated,
131
+ omittedCount,
132
+ };
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Markdown rendering
136
+ // ---------------------------------------------------------------------------
137
+ function renderLogMarkdown(group, filterSummary) {
138
+ const lines = [];
139
+ lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
140
+ lines.push(`_root: ${group.workspace_root}_`);
141
+ lines.push("");
142
+ if (group.commits.length === 0) {
143
+ lines.push("_(no commits match)_");
144
+ }
145
+ else {
146
+ for (const c of group.commits) {
147
+ lines.push(`- \`${c.sha7}\` ${c.ageRelative} ${c.subject} — ${c.author}`);
148
+ }
149
+ }
150
+ if (group.truncated) {
151
+ lines.push("");
152
+ lines.push(`_(truncated — ${group.omittedCount} more commit(s) not shown; lower \`since\` or \`maxCommits\`)_`);
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Tool registration
158
+ // ---------------------------------------------------------------------------
159
+ export function registerGitLogTool(server) {
160
+ server.addTool({
161
+ name: "git_log",
162
+ description: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
163
+ "Returns structured commit history with author, date, subject, and optional diff stats. " +
164
+ "See docs/mcp-tools.md.",
165
+ annotations: {
166
+ readOnlyHint: true,
167
+ },
168
+ parameters: WorkspacePickSchema.extend({
169
+ since: z
170
+ .string()
171
+ .optional()
172
+ .describe("Passed to `git log --since=`. Accepts ISO timestamps or git relative forms like " +
173
+ "`48.hours` or `2.weeks.ago`. Default: `7.days`."),
174
+ paths: z
175
+ .array(z.string())
176
+ .optional()
177
+ .describe("Limit to commits touching these paths (passed as `-- <paths>`)."),
178
+ grep: z
179
+ .string()
180
+ .optional()
181
+ .describe("Filter commits whose message matches this regex (git `--grep`, case-insensitive)."),
182
+ author: z
183
+ .string()
184
+ .optional()
185
+ .describe("Filter by author name or email (passed as `--author=`)."),
186
+ maxCommits: z
187
+ .number()
188
+ .int()
189
+ .min(1)
190
+ .max(MAX_COMMITS_HARD_CAP)
191
+ .optional()
192
+ .default(DEFAULT_MAX_COMMITS)
193
+ .describe(`Maximum commits to return per root (hard cap ${MAX_COMMITS_HARD_CAP}). Default ${DEFAULT_MAX_COMMITS}.`),
194
+ branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
195
+ }),
196
+ execute: async (args) => {
197
+ const pre = requireGitAndRoots(server, args, undefined);
198
+ if (!pre.ok)
199
+ return jsonRespond(pre.error);
200
+ // Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
201
+ const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
202
+ if (/[\n\r;|&`$<>]/.test(rawSince)) {
203
+ return jsonRespond({ error: "invalid_since", since: rawSince });
204
+ }
205
+ // Validate paths — reject anything with null bytes or shell meta.
206
+ // Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
207
+ const rawPaths = args.paths ?? [];
208
+ for (const p of rawPaths) {
209
+ if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
210
+ return jsonRespond({ error: "invalid_paths", path: p });
211
+ }
212
+ }
213
+ const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
214
+ // Fan out across roots.
215
+ const jobs = pre.roots.map((rootInput) => ({ rootInput }));
216
+ const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
217
+ const top = gitTopLevel(rootInput);
218
+ if (!top) {
219
+ return { _error: true, workspace_root: rootInput, error: "not_a_git_repo" };
220
+ }
221
+ const r = await runGitLog({
222
+ top,
223
+ since: rawSince,
224
+ paths: rawPaths,
225
+ grep: args.grep,
226
+ author: args.author,
227
+ maxCommits,
228
+ branch: args.branch,
229
+ });
230
+ if ("error" in r) {
231
+ return { _error: true, workspace_root: rootInput, error: r.error };
232
+ }
233
+ return { _error: false, ...r };
234
+ });
235
+ // Build filter summary string for markdown.
236
+ const filterParts = [`since: ${rawSince}`];
237
+ if (rawPaths.length > 0)
238
+ filterParts.push(`paths: ${rawPaths.join(", ")}`);
239
+ if (args.grep)
240
+ filterParts.push(`grep: ${args.grep}`);
241
+ if (args.author)
242
+ filterParts.push(`author: ${args.author}`);
243
+ const filterSummary = filterParts.join(" | ");
244
+ if (args.format === "json") {
245
+ const groups = results.map((r) => {
246
+ if (r._error) {
247
+ return {
248
+ workspace_root: r.workspace_root,
249
+ repo: basename(r.workspace_root),
250
+ branch: "",
251
+ commits: [],
252
+ ...spreadWhen(true, { error: r.error }),
253
+ };
254
+ }
255
+ const { _error: _e, ...rest } = r;
256
+ return {
257
+ ...rest,
258
+ ...spreadWhen(r.truncated, { truncated: true, omittedCount: r.omittedCount }),
259
+ };
260
+ });
261
+ return jsonRespond({ groups });
262
+ }
263
+ // Markdown
264
+ const mdChunks = ["# Git log"];
265
+ for (const r of results) {
266
+ if (r._error) {
267
+ mdChunks.push(`### ${r.workspace_root}\n_error: ${r.error}_`);
268
+ continue;
269
+ }
270
+ const { _error: _e, ...group } = r;
271
+ mdChunks.push(renderLogMarkdown(group, filterSummary));
272
+ }
273
+ return mdChunks.join("\n\n");
274
+ },
275
+ });
276
+ }