@synmux/claude-commit 1.0.1 → 1.0.3

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/src/ollama.ts ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * The Ollama backend: one prompt to one local (or self-hosted) model.
3
+ *
4
+ * cco speaks Ollama's **native** `/api/chat`, not either of its compatibility
5
+ * layers. The OpenAI layer has no field for the context length, and cco sizes
6
+ * every diff chunk against a context window, so a dialect that cannot state
7
+ * one is unusable here; the Anthropic layer exists to let Anthropic SDK
8
+ * clients point at Ollama, and cco does not talk raw Anthropic - it talks
9
+ * Agent SDK, which spawns its own binary. The native API gives
10
+ * `options.num_ctx`, structured output via `format`, and the usage counts
11
+ * that make a truncated prompt detectable.
12
+ *
13
+ * There is no SDK dependency: one `fetch` against one endpoint, so nothing
14
+ * here assumes a particular JavaScript runtime.
15
+ *
16
+ * ## Two failure modes worth knowing about
17
+ *
18
+ * **A prompt over the context window is truncated silently.** Ollama drops
19
+ * the oldest content, returns HTTP 200, and flags nothing. A summary written
20
+ * from half a diff is worse than no summary, so every request pins
21
+ * `options.num_ctx` to the same number the chunks were sized against, and
22
+ * the response's `prompt_eval_count` is checked against it afterwards.
23
+ * Where that number comes from is {@link resolveOllamaContext}: a configured
24
+ * token count, or - by default - the window Ollama itself picks for the
25
+ * model on this machine, read back from `/api/ps` after a preload.
26
+ * Reaching the limit means content was dropped, and cco raises an error
27
+ * whose text contains "prompt is too long" - the phrase
28
+ * {@link isPromptTooLongError} matches - so the pipeline's existing
29
+ * halve-and-re-split retry handles it exactly as it handles a Claude
30
+ * overflow. Ollama has no rejection of its own to trigger that path, so this
31
+ * synthesises one.
32
+ *
33
+ * **An error can arrive after HTTP 200.** In a streamed response it is a
34
+ * plain NDJSON line `{"error": "..."}` partway through, long after the
35
+ * status line said everything was fine, so every parsed line is checked for
36
+ * it rather than trusting the status code.
37
+ */
38
+ import { ClaudeCommitError } from "./errors";
39
+ import {
40
+ DEFAULT_OLLAMA_CONTEXT,
41
+ DEFAULT_OLLAMA_HOST,
42
+ parseModelRef,
43
+ } from "./models";
44
+ import type { ModelResult, OllamaConfig, RunPromptOptions } from "./types";
45
+
46
+ /** Ollama settings with every default filled in; the context may still be `"auto"`. */
47
+ export interface ResolvedOllama {
48
+ host: string;
49
+ context: number | "auto";
50
+ keepAlive: string | number | null;
51
+ }
52
+
53
+ /** {@link ResolvedOllama} after `"auto"` has been turned into a number. */
54
+ export interface OllamaRequestSettings {
55
+ host: string;
56
+ contextTokens: number;
57
+ keepAlive: string | number | null;
58
+ }
59
+
60
+ /**
61
+ * Normalise a base URL: add a scheme to a bare `host:port` and drop any
62
+ * trailing slash. Ollama's own `OLLAMA_HOST` convention allows the bare
63
+ * form, so `127.0.0.1:11434` has to mean what a user expects it to.
64
+ */
65
+ export function normaliseOllamaHost(host: string): string {
66
+ const trimmed = host.trim().replace(/\/+$/, "");
67
+ if (trimmed === "") return DEFAULT_OLLAMA_HOST;
68
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
69
+ ? trimmed
70
+ : `http://${trimmed}`;
71
+ }
72
+
73
+ /**
74
+ * The Ollama base URL for this run: the configured `ollama.host`, else
75
+ * `$OLLAMA_HOST`, else {@link DEFAULT_OLLAMA_HOST}.
76
+ */
77
+ export function resolveOllamaHost(
78
+ configured?: string,
79
+ env: Record<string, string | undefined> = process.env,
80
+ ): string {
81
+ const candidate = configured?.trim() || env.OLLAMA_HOST?.trim() || "";
82
+ return normaliseOllamaHost(candidate);
83
+ }
84
+
85
+ /**
86
+ * Fill in defaults for any Ollama setting the config left out. A missing
87
+ * or unusable `context` becomes `"auto"`; turning that into a number is
88
+ * {@link resolveOllamaContext}'s job, because it takes a round trip.
89
+ */
90
+ export function resolveOllamaConfig(
91
+ config: Partial<OllamaConfig> | undefined,
92
+ env: Record<string, string | undefined> = process.env,
93
+ ): ResolvedOllama {
94
+ const context = config?.context;
95
+ return {
96
+ host: resolveOllamaHost(config?.host, env),
97
+ context:
98
+ typeof context === "number" && context > 0
99
+ ? Math.floor(context)
100
+ : DEFAULT_OLLAMA_CONTEXT,
101
+ keepAlive: config?.keepAlive ?? null,
102
+ };
103
+ }
104
+
105
+ /** The one field of a `/api/ps` entry cco reads, plus the names it matches on. */
106
+ interface OllamaLoadedModel {
107
+ name?: string;
108
+ model?: string;
109
+ context_length?: number;
110
+ }
111
+
112
+ /**
113
+ * Ask Ollama what context window it would run `model` with on this machine.
114
+ *
115
+ * Two calls. The first is a chat request with no messages, which loads the
116
+ * model (a no-op if it is already resident) *without* a `num_ctx` - so the
117
+ * server applies its own choice, made from available VRAM (4k / 32k / 256k
118
+ * tiers, capped at the model's trained maximum). The second reads that
119
+ * choice back from `/api/ps`, which reports the window each loaded model is
120
+ * actually running with. The load was going to happen on the first real
121
+ * request anyway, so the only added cost is the `ps` round trip.
122
+ *
123
+ * This is deliberately not `/api/show`'s `context_length`, which is the
124
+ * *trained* maximum regardless of hardware - 131072 for a model this
125
+ * machine may only be able to run at 32768. The number the server picked
126
+ * is the one it can actually load.
127
+ */
128
+ export async function probeOllamaContext(
129
+ model: string,
130
+ settings: { host: string; keepAlive: string | number | null },
131
+ signal?: AbortSignal,
132
+ ): Promise<number> {
133
+ const { host, keepAlive } = settings;
134
+ const preload = await ollamaFetch(
135
+ `${host}/api/chat`,
136
+ {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({
140
+ model,
141
+ messages: [],
142
+ stream: false,
143
+ ...(keepAlive !== null ? { keep_alive: keepAlive } : {}),
144
+ }),
145
+ },
146
+ host,
147
+ signal,
148
+ );
149
+ if (!preload.ok) {
150
+ throw new ClaudeCommitError(
151
+ await describeHttpFailure(preload, host, model),
152
+ );
153
+ }
154
+
155
+ const ps = await ollamaFetch(
156
+ `${host}/api/ps`,
157
+ { method: "GET" },
158
+ host,
159
+ signal,
160
+ );
161
+ if (!ps.ok) {
162
+ throw new ClaudeCommitError(await describeHttpFailure(ps, host, model));
163
+ }
164
+ const body = (await ps.json()) as { models?: OllamaLoadedModel[] };
165
+ const loaded = (body.models ?? []).find(
166
+ (entry) => entry.name === model || entry.model === model,
167
+ );
168
+ const contextLength = loaded?.context_length;
169
+ if (typeof contextLength !== "number" || contextLength <= 0) {
170
+ throw new ClaudeCommitError(
171
+ `Ollama loaded "${model}" but did not report its context window in ` +
172
+ `/api/ps, so cco cannot size the diff for it. Set "ollama.context" ` +
173
+ `to a token count to pin one.`,
174
+ );
175
+ }
176
+ return Math.floor(contextLength);
177
+ }
178
+
179
+ /**
180
+ * The context window to use for `model`: the configured number, or the
181
+ * server's own choice when the config says `"auto"` (see
182
+ * {@link probeOllamaContext}). Callers that make several requests to the
183
+ * same model should resolve once and reuse the result.
184
+ */
185
+ export async function resolveOllamaContext(
186
+ model: string,
187
+ config: Partial<OllamaConfig> | undefined,
188
+ signal?: AbortSignal,
189
+ ): Promise<number> {
190
+ const resolved = resolveOllamaConfig(config);
191
+ if (resolved.context !== "auto") return resolved.context;
192
+ const { name } = parseModelRef(model);
193
+ return probeOllamaContext(name, resolved, signal);
194
+ }
195
+
196
+ /** `fetch` with transport failures and cancellation turned into cco errors. */
197
+ async function ollamaFetch(
198
+ url: string,
199
+ init: RequestInit,
200
+ host: string,
201
+ signal?: AbortSignal,
202
+ ): Promise<Response> {
203
+ try {
204
+ return await fetch(url, { ...init, ...(signal ? { signal } : {}) });
205
+ } catch (error) {
206
+ if (signal?.aborted) {
207
+ throw new ClaudeCommitError("Generation was cancelled.");
208
+ }
209
+ throw new ClaudeCommitError(describeTransportFailure(error, host));
210
+ }
211
+ }
212
+
213
+ /** The body of a native `/api/chat` request. */
214
+ export interface OllamaChatRequest {
215
+ model: string;
216
+ messages: Array<{ role: "system" | "user"; content: string }>;
217
+ stream: boolean;
218
+ format?: Record<string, unknown>;
219
+ keep_alive?: string | number;
220
+ options: Record<string, unknown>;
221
+ }
222
+
223
+ /**
224
+ * Build the `/api/chat` body for one prompt.
225
+ *
226
+ * Two things are deliberate. Sampling parameters go inside `options` - at the
227
+ * top level Ollama accepts and silently ignores them, so a misplaced
228
+ * `temperature` would look like a model that refuses to vary. And `think` is
229
+ * never sent at all: models disagree on whether reasoning can be switched
230
+ * off (gpt-oss cannot, and takes only a level; Granite uses its own field
231
+ * entirely), so asking is a needless way to earn a 400. Any `thinking` that
232
+ * comes back is dropped on the floor instead.
233
+ */
234
+ export function buildChatRequest(
235
+ prompt: string,
236
+ opts: RunPromptOptions,
237
+ settings: OllamaRequestSettings,
238
+ ): OllamaChatRequest {
239
+ const { name } = parseModelRef(opts.model);
240
+ const options: Record<string, unknown> = { num_ctx: settings.contextTokens };
241
+ if (opts.temperature != null) options.temperature = opts.temperature;
242
+
243
+ return {
244
+ model: name,
245
+ messages: [
246
+ { role: "system", content: opts.system },
247
+ { role: "user", content: prompt },
248
+ ],
249
+ // Stream only when someone is watching the text arrive. A single JSON
250
+ // body is easier to get right, and is what Ollama's own guidance
251
+ // recommends for structured output.
252
+ stream: Boolean(opts.onText),
253
+ ...(opts.outputFormat ? { format: opts.outputFormat.schema } : {}),
254
+ ...(settings.keepAlive !== null ? { keep_alive: settings.keepAlive } : {}),
255
+ options,
256
+ };
257
+ }
258
+
259
+ /** The fields of a chat response cco actually reads. */
260
+ interface OllamaChatChunk {
261
+ model?: string;
262
+ message?: { content?: string; thinking?: string };
263
+ done?: boolean;
264
+ done_reason?: string;
265
+ prompt_eval_count?: number;
266
+ prompt_eval_cached_count?: number;
267
+ eval_count?: number;
268
+ error?: string;
269
+ }
270
+
271
+ /** Turn a non-2xx response into a message that says what to do about it. */
272
+ async function describeHttpFailure(
273
+ response: Response,
274
+ host: string,
275
+ model: string,
276
+ ): Promise<string> {
277
+ let detail = "";
278
+ try {
279
+ const body: unknown = await response.json();
280
+ if (body && typeof body === "object" && "error" in body) {
281
+ detail = String((body as { error: unknown }).error);
282
+ }
283
+ } catch {
284
+ /* a non-JSON error body tells us nothing extra */
285
+ }
286
+
287
+ switch (response.status) {
288
+ case 404:
289
+ return (
290
+ `Ollama has no model "${model}" on ${host}. Pull it first with ` +
291
+ `\`ollama pull ${model}\`, or check the exact name with \`ollama list\`.`
292
+ );
293
+ case 400:
294
+ return (
295
+ `Ollama rejected the request for "${model}"${detail ? `: ${detail}` : ""}. ` +
296
+ `Check the model supports plain chat completion (\`ollama show ${model}\`).`
297
+ );
298
+ case 401:
299
+ case 403:
300
+ return `Ollama at ${host} refused the request as unauthorised${detail ? `: ${detail}` : ""}.`;
301
+ case 429:
302
+ return `Ollama at ${host} is rate limiting requests. Try again shortly.`;
303
+ case 500:
304
+ return (
305
+ `Ollama failed to run "${model}"${detail ? `: ${detail}` : ""}. ` +
306
+ `This is often the model runner running out of memory - set ` +
307
+ `"ollama.context" to a smaller number or use a smaller model.`
308
+ );
309
+ case 503:
310
+ return `Ollama at ${host} has a full request queue. Try again shortly.`;
311
+ default:
312
+ return (
313
+ `Ollama at ${host} returned ${response.status} ${response.statusText}` +
314
+ (detail ? `: ${detail}` : "") +
315
+ "."
316
+ );
317
+ }
318
+ }
319
+
320
+ /** Turn a transport-level failure into a message that says what to do about it. */
321
+ function describeTransportFailure(error: unknown, host: string): string {
322
+ const message = error instanceof Error ? error.message : String(error);
323
+ if (
324
+ /econnrefused|failed to fetch|unable to connect|connection refused/i.test(
325
+ message,
326
+ )
327
+ ) {
328
+ return (
329
+ `Cannot reach the Ollama server at ${host}. Start it with ` +
330
+ `\`ollama serve\`, or set "ollama.host" in your claude-commit config.`
331
+ );
332
+ }
333
+ return `Failed to call Ollama at ${host}: ${message}`;
334
+ }
335
+
336
+ /**
337
+ * Read one NDJSON stream, forwarding text deltas and returning the final
338
+ * chunk. Each line is checked for an `error` key: a mid-stream failure
339
+ * arrives that way, after the 200 has already been sent, so a status check
340
+ * alone would miss it.
341
+ */
342
+ async function consumeStream(
343
+ response: Response,
344
+ onText: ((delta: string) => void) | undefined,
345
+ ): Promise<{ content: string; final: OllamaChatChunk }> {
346
+ const body = response.body;
347
+ if (!body) throw new ClaudeCommitError("Ollama returned an empty response.");
348
+
349
+ const reader = body.getReader();
350
+ const decoder = new TextDecoder();
351
+ let buffer = "";
352
+ let content = "";
353
+ let final: OllamaChatChunk = {};
354
+
355
+ const handleLine = (line: string) => {
356
+ const trimmed = line.trim();
357
+ if (trimmed === "") return;
358
+ let chunk: OllamaChatChunk;
359
+ try {
360
+ chunk = JSON.parse(trimmed) as OllamaChatChunk;
361
+ } catch {
362
+ throw new ClaudeCommitError(
363
+ `Ollama sent a malformed response line: ${trimmed.slice(0, 200)}`,
364
+ );
365
+ }
366
+ if (chunk.error) throw new ClaudeCommitError(`Ollama: ${chunk.error}`);
367
+ const delta = chunk.message?.content ?? "";
368
+ if (delta !== "") {
369
+ content += delta;
370
+ onText?.(delta);
371
+ }
372
+ if (chunk.done) final = chunk;
373
+ };
374
+
375
+ for (;;) {
376
+ const { value, done } = await reader.read();
377
+ if (done) break;
378
+ buffer += decoder.decode(value, { stream: true });
379
+ let newline: number;
380
+ while ((newline = buffer.indexOf("\n")) >= 0) {
381
+ const line = buffer.slice(0, newline);
382
+ buffer = buffer.slice(newline + 1);
383
+ handleLine(line);
384
+ }
385
+ }
386
+ buffer += decoder.decode();
387
+ handleLine(buffer);
388
+
389
+ if (!final.done) {
390
+ throw new ClaudeCommitError(
391
+ "Ollama's response ended before the model finished.",
392
+ );
393
+ }
394
+ return { content, final };
395
+ }
396
+
397
+ /**
398
+ * Tokens the server reported for the prompt. `prompt_eval_cached_count` is
399
+ * documented as its own counter without saying whether cached tokens are
400
+ * also inside `prompt_eval_count`, so take the larger: under either reading
401
+ * that is the prompt's real size, and neither double-counts.
402
+ */
403
+ function promptTokensOf(final: OllamaChatChunk): number {
404
+ return Math.max(
405
+ final.prompt_eval_count ?? 0,
406
+ final.prompt_eval_cached_count ?? 0,
407
+ );
408
+ }
409
+
410
+ /**
411
+ * Run a single prompt against an Ollama model and return its response.
412
+ *
413
+ * Throws {@link ClaudeCommitError} on any transport, model or truncation
414
+ * failure. `costUsd` is always zero: local inference is not billed, so a
415
+ * mixed-provider run's reported cost is exactly its Claude half.
416
+ */
417
+ export async function runOllamaPrompt(
418
+ prompt: string,
419
+ opts: RunPromptOptions,
420
+ ): Promise<ModelResult> {
421
+ const { name } = parseModelRef(opts.model);
422
+ const signal = opts.abortController?.signal;
423
+ // A caller that has already resolved `"auto"` (the pipeline does, once
424
+ // per model) passes a number through and pays nothing here; a direct
425
+ // caller with `"auto"` pays the probe on every call.
426
+ const base = resolveOllamaConfig(opts.ollama);
427
+ const resolved: OllamaRequestSettings = {
428
+ host: base.host,
429
+ keepAlive: base.keepAlive,
430
+ contextTokens: await resolveOllamaContext(opts.model, opts.ollama, signal),
431
+ };
432
+ const request = buildChatRequest(prompt, opts, resolved);
433
+
434
+ const response = await ollamaFetch(
435
+ `${resolved.host}/api/chat`,
436
+ {
437
+ method: "POST",
438
+ headers: { "Content-Type": "application/json" },
439
+ body: JSON.stringify(request),
440
+ },
441
+ resolved.host,
442
+ signal,
443
+ );
444
+ if (!response.ok) {
445
+ throw new ClaudeCommitError(
446
+ await describeHttpFailure(response, resolved.host, name),
447
+ );
448
+ }
449
+
450
+ let content: string;
451
+ let final: OllamaChatChunk;
452
+ if (request.stream) {
453
+ ({ content, final } = await consumeStream(response, opts.onText));
454
+ } else {
455
+ final = (await response.json()) as OllamaChatChunk;
456
+ if (final.error) throw new ClaudeCommitError(`Ollama: ${final.error}`);
457
+ content = final.message?.content ?? "";
458
+ }
459
+
460
+ // The prompt filled the window, which means Ollama dropped whatever did
461
+ // not fit rather than complaining. Phrase it so the pipeline's overflow
462
+ // retry recognises it and re-splits the chunk.
463
+ const promptTokens = promptTokensOf(final);
464
+ if (promptTokens > 0 && promptTokens >= resolved.contextTokens) {
465
+ throw new ClaudeCommitError(
466
+ `Ollama truncated the request to "${name}": the prompt is too long for ` +
467
+ `the ${resolved.contextTokens}-token context window ("ollama.context").`,
468
+ );
469
+ }
470
+
471
+ if (final.done_reason === "length") {
472
+ throw new ClaudeCommitError(
473
+ `Ollama's reply from "${name}" was cut off at the context limit. ` +
474
+ `Raise "ollama.context" beyond ${resolved.contextTokens}, or use ` +
475
+ `a model with more room.`,
476
+ );
477
+ }
478
+
479
+ const text = content.trim();
480
+ if (text === "") {
481
+ throw new ClaudeCommitError(`Ollama model "${name}" returned no text.`);
482
+ }
483
+
484
+ let structured: unknown;
485
+ if (opts.outputFormat) {
486
+ try {
487
+ structured = JSON.parse(text);
488
+ } catch {
489
+ // Leave `structured` unset: the caller's fallback chain drops to a
490
+ // plain-text attempt, which is exactly the right response to a model
491
+ // or server that could not honour the schema (Ollama Cloud, for one,
492
+ // does not support `format` at all).
493
+ }
494
+ }
495
+
496
+ return {
497
+ text,
498
+ costUsd: 0,
499
+ ...(final.model ? { model: final.model } : {}),
500
+ ...(structured !== undefined ? { structured } : {}),
501
+ };
502
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Path matching for the path-list configuration options - `lowPriorityPaths`
3
+ * (weigh these changes less) and `ignore` (do not read these changes at all).
4
+ * Both take the same pattern language, so both compile through here.
5
+ *
6
+ * Patterns follow gitignore conventions rather than raw glob semantics,
7
+ * because that is what users reach for when they write
8
+ * `.agents/skills/*-skilld` and expect it to cover every file beneath each
9
+ * matching directory. `Bun.Glob` does the wildcard work (no dependency, `*`
10
+ * matches dotfiles, `**` crosses directories, braces expand, `\` escapes);
11
+ * this module adds the gitignore-style rules on top:
12
+ *
13
+ * - A pattern containing a `/` (anywhere but the end) is anchored at the
14
+ * repository root and matches a path when the glob matches the path
15
+ * itself **or any ancestor directory** of it.
16
+ * - A pattern without a `/` matches when the glob matches **any path
17
+ * segment** - the file's basename or any ancestor directory's name - so
18
+ * `bun.lock` or `*-skilld` apply at any depth.
19
+ * - A leading `/` or `./` anchors a pattern that would otherwise be bare; a
20
+ * trailing `/` is accepted (gitignore's "directory only" marker) and
21
+ * ignored, since the ancestor rule already covers a directory's contents.
22
+ * - A leading `!` negates: patterns are evaluated in order and the last one
23
+ * that matches decides, so `["docs/**", "!docs/adr/**"]` selects
24
+ * docs except the ADRs. `\!` matches a literal leading bang.
25
+ *
26
+ * Paths are always repository-root-relative with `/` separators, which is
27
+ * what git emits on every platform (`getStagedDiff` forces `--no-relative`);
28
+ * a backslash in a path is a filename character, never a separator. The
29
+ * anchored/bare decision is made on the whole pattern text, so a `/` inside
30
+ * a brace group anchors every alternative - prefer one pattern per intent.
31
+ *
32
+ * An ill-formed pattern never throws, but `Bun.Glob` parses it rather than
33
+ * rejecting it, so it does not reliably match nothing: an unbalanced `{` is
34
+ * treated as its first alternative (`{docs,build` matches `docs` at any
35
+ * depth and never `build`), while an unterminated `[` matches nothing at
36
+ * all, not even its own text (write `\[abc` for that). No construction-time
37
+ * check can catch this - gitignore does not validate either - so the
38
+ * failure mode is a silently mis-classified diff, made visible by the
39
+ * `--verbose` match counts rather than prevented.
40
+ */
41
+ import { Glob } from "bun";
42
+
43
+ /** A predicate over repository-relative paths. */
44
+ export type PathMatcher = (path: string) => boolean;
45
+
46
+ interface CompiledPattern {
47
+ glob: Glob;
48
+ /** Match against the path and its ancestors (true) or against each segment (false). */
49
+ anchored: boolean;
50
+ /** A `!` pattern: a match un-marks the path instead of marking it. */
51
+ negated: boolean;
52
+ }
53
+
54
+ /** Normalise a repository-relative path: no leading `./` or `/`. */
55
+ function normalisePath(path: string): string {
56
+ let normalised = path;
57
+ while (normalised.startsWith("./")) normalised = normalised.slice(2);
58
+ return normalised.replace(/^\/+/, "");
59
+ }
60
+
61
+ /** Compile one raw pattern, or `null` when nothing remains after normalising. */
62
+ function compilePattern(raw: string): CompiledPattern | null {
63
+ let pattern = raw.trim();
64
+ if (pattern === "") return null;
65
+
66
+ let negated = false;
67
+ if (pattern.startsWith("!")) {
68
+ negated = true;
69
+ pattern = pattern.slice(1).trim();
70
+ if (pattern === "") return null;
71
+ }
72
+
73
+ let anchored = false;
74
+ // `./x` is what shell completion produces at the repo root: anchor it.
75
+ while (pattern.startsWith("./")) {
76
+ anchored = true;
77
+ pattern = pattern.slice(2);
78
+ }
79
+
80
+ // gitignore's trailing slash ("directory only") - drop it; the ancestor
81
+ // rule already makes a directory pattern cover everything beneath it.
82
+ while (pattern.length > 1 && pattern.endsWith("/")) {
83
+ pattern = pattern.slice(0, -1);
84
+ }
85
+
86
+ if (pattern.startsWith("/")) {
87
+ anchored = true;
88
+ pattern = pattern.replace(/^\/+/, "");
89
+ }
90
+ if (pattern === "") return null;
91
+ if (pattern.includes("/")) anchored = true;
92
+
93
+ return { glob: new Glob(pattern), anchored, negated };
94
+ }
95
+
96
+ function matchesCompiled(
97
+ segments: string[],
98
+ compiled: CompiledPattern,
99
+ ): boolean {
100
+ if (compiled.anchored) {
101
+ // The path itself first, then each ancestor directory, longest first.
102
+ for (let length = segments.length; length >= 1; length--) {
103
+ if (compiled.glob.match(segments.slice(0, length).join("/"))) {
104
+ return true;
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ return segments.some((segment) => compiled.glob.match(segment));
110
+ }
111
+
112
+ /**
113
+ * Build a matcher for a list of gitignore-style patterns. Compile once per
114
+ * run and reuse it across every path in the diff.
115
+ */
116
+ export function createPathMatcher(patterns: string[]): PathMatcher {
117
+ const compiled = patterns
118
+ .map(compilePattern)
119
+ .filter((entry): entry is CompiledPattern => entry !== null);
120
+ if (compiled.length === 0) return () => false;
121
+
122
+ return (path: string): boolean => {
123
+ const segments = normalisePath(path)
124
+ .split("/")
125
+ .filter((segment) => segment !== "");
126
+ if (segments.length === 0) return false;
127
+ // gitignore semantics: the last pattern that matches decides.
128
+ let verdict = false;
129
+ for (const entry of compiled) {
130
+ if (matchesCompiled(segments, entry)) verdict = !entry.negated;
131
+ }
132
+ return verdict;
133
+ };
134
+ }
135
+
136
+ /** Whether `path` matches any of the gitignore-style `patterns`. */
137
+ export function matchesPathPatterns(path: string, patterns: string[]): boolean {
138
+ return createPathMatcher(patterns)(path);
139
+ }