mixdog 0.9.132 → 0.9.133

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.132",
3
+ "version": "0.9.133",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -22,7 +22,6 @@ import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../provide
22
22
  import {
23
23
  _stripMcpPrefix,
24
24
  _isReadTool,
25
- _isMutationTool,
26
25
  _isScopedCacheableTool,
27
26
  _isShellTool,
28
27
  _intraTurnSig,
@@ -659,7 +658,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
659
658
  });
660
659
  const _providerMessages = _evidenceProjection.messages;
661
660
  if (_evidenceProjection.stats.reusedRows > 0
662
- || _evidenceProjection.stats.exactResultRefs > 0) {
661
+ || _evidenceProjection.stats.exactResultRefs > 0
662
+ || _evidenceProjection.stats.pathAliases > 0) {
663
663
  try {
664
664
  const _evidencePayload = {
665
665
  shadow: _evidenceUnionShadow,
@@ -671,6 +671,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
671
671
  changed_tool_results: _evidenceProjection.stats.changedToolResults,
672
672
  exact_result_refs: _evidenceProjection.stats.exactResultRefs,
673
673
  exact_result_bytes_saved: _evidenceProjection.stats.exactResultBytesSaved,
674
+ path_facts: _evidenceProjection.stats.pathFacts,
675
+ path_aliases: _evidenceProjection.stats.pathAliases,
676
+ reused_path_facts: _evidenceProjection.stats.reusedPathFacts,
677
+ path_alias_bytes_saved: _evidenceProjection.stats.pathAliasBytesSaved,
674
678
  };
675
679
  appendAgentTrace({
676
680
  sessionId,
@@ -1,8 +1,8 @@
1
1
  // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
2
  // per-turn pending promise map, the intra-turn in-flight signature set, and
3
3
  // the mutation epoch. Every valid call dispatches while the provider is still
4
- // streaming. Calls execute in parallel except that shell after apply_patch
5
- // waits for every earlier patch in the turn and runs only if all succeeded;
4
+ // streaming. Calls execute in parallel except that repository-wide Git writes
5
+ // serialize against file edits, while shell waits for every earlier mutation;
6
6
  // results are collected later in call order.
7
7
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
8
8
  import { classifyResultKind } from './result-classification.mjs';
@@ -11,6 +11,7 @@ import {
11
11
  _intraTurnSig,
12
12
  _argShapeSig,
13
13
  _isEditTool,
14
+ _isGitMutationTool,
14
15
  _isMutationTool,
15
16
  _isReadTool,
16
17
  _isScopedCacheableTool,
@@ -46,6 +47,9 @@ export function createEagerDispatcher({
46
47
  // Exact-string edits are single-replacement calls. Serialize them so
47
48
  // multiple edits to one file in the same model turn observe prior writes.
48
49
  let editBarrier = Promise.resolve();
50
+ // Git mutations have repository-wide effects. They wait for earlier
51
+ // file edits, and later edits/shell verification wait for them.
52
+ let gitMutationBarrier = Promise.resolve();
49
53
  // Streaming-time intra-turn dedup. When the LLM emits two
50
54
  // tool_use blocks with identical (name, args) signatures in
51
55
  // sequence, the provider's onToolCall fires for both BEFORE
@@ -131,19 +135,23 @@ export function createEagerDispatcher({
131
135
  localSearchTelemetry: {},
132
136
  resultTelemetry: {},
133
137
  };
134
- const precedingPatches = _isShellTool(call.name) ? patchBarrier : null;
138
+ const gitMutation = _isGitMutationTool(call.name, call.arguments);
139
+ const mutation = _isMutationTool(call.name, call.arguments);
140
+ const precedingPatches = (_isShellTool(call.name) || gitMutation) ? patchBarrier : null;
141
+ const precedingGitMutation = (_isShellTool(call.name) || mutation) ? gitMutationBarrier : null;
135
142
  const precedingEdits = _isEditTool(call.name) ? editBarrier : null;
136
143
  if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
137
144
  entry.promise = (async () => {
138
145
  try {
139
146
  if (precedingEdits) await precedingEdits;
147
+ if (precedingGitMutation) await precedingGitMutation;
140
148
  if (precedingPatches) {
141
149
  const patchState = await precedingPatches;
142
150
  if (patchState.failedPatchIds.length > 0) {
143
151
  return {
144
152
  ok: true,
145
153
  skipped: true,
146
- value: `[patch-dependency-guard] \`${call.name}\` skipped because earlier file-edit call(s) failed: ${patchState.failedPatchIds.join(', ')}; no verification ran.`,
154
+ value: `[mutation-dependency-guard] \`${call.name}\` skipped because earlier mutation call(s) failed: ${patchState.failedPatchIds.join(', ')}; no verification ran.`,
147
155
  };
148
156
  }
149
157
  }
@@ -215,7 +223,7 @@ export function createEagerDispatcher({
215
223
  if (_isEditTool(call.name)) {
216
224
  editBarrier = entry.promise.then(() => undefined, () => undefined);
217
225
  }
218
- if (_isMutationTool(call.name)) {
226
+ if (_isMutationTool(call.name, call.arguments)) {
219
227
  const precedingPatchState = patchBarrier;
220
228
  const currentPatch = entry.promise;
221
229
  patchBarrier = Promise.all([precedingPatchState, currentPatch]).then(([state, settled]) => ({
@@ -224,6 +232,9 @@ export function createEagerDispatcher({
224
232
  : state.failedPatchIds,
225
233
  }));
226
234
  }
235
+ if (gitMutation) {
236
+ gitMutationBarrier = entry.promise.then(() => undefined, () => undefined);
237
+ }
227
238
  return entry;
228
239
  };
229
240
  const startEagerRun = (calls, startIndex, dupSet) => {
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createEagerDispatcher } from './eager-dispatch.mjs';
4
+
5
+ function gate() {
6
+ let release;
7
+ return { promise: new Promise((resolve) => { release = resolve; }), release };
8
+ }
9
+
10
+ test('eager dispatch serializes Git mutations, file edits, and shell verification', async () => {
11
+ const gitGate = gate();
12
+ const patchGate = gate();
13
+ const events = [];
14
+ const executeToolFn = async (name) => {
15
+ events.push(`${name}:start`);
16
+ if (name === 'git') await gitGate.promise;
17
+ if (name === 'apply_patch') await patchGate.promise;
18
+ events.push(`${name}:end`);
19
+ return { result: 'ok', explicitSuccess: true };
20
+ };
21
+ const dispatcher = createEagerDispatcher({
22
+ tools: [],
23
+ cwd: process.cwd(),
24
+ sessionId: null,
25
+ sessionRef: {},
26
+ signal: null,
27
+ opts: {},
28
+ crossTurnCalls: new Map(),
29
+ getIterations: () => 1,
30
+ getNextIteration: () => 1,
31
+ repeatFailLimit: 3,
32
+ executeToolFn,
33
+ });
34
+ const calls = [
35
+ { id: 'git', name: 'git', arguments: { command: 'git add --all' } },
36
+ { id: 'patch', name: 'apply_patch', arguments: { patch: 'test' } },
37
+ { id: 'shell', name: 'shell', arguments: { command: 'git status' } },
38
+ ];
39
+
40
+ dispatcher.startEagerRun(calls, 0, new Set());
41
+ await new Promise((resolve) => setImmediate(resolve));
42
+ assert.deepEqual(events, ['git:start']);
43
+
44
+ gitGate.release();
45
+ await new Promise((resolve) => setImmediate(resolve));
46
+ assert.deepEqual(events, ['git:start', 'git:end', 'apply_patch:start']);
47
+
48
+ patchGate.release();
49
+ await Promise.all([...dispatcher.pending.values()].map((entry) => entry.promise));
50
+ assert.deepEqual(events, [
51
+ 'git:start', 'git:end',
52
+ 'apply_patch:start', 'apply_patch:end',
53
+ 'shell:start', 'shell:end',
54
+ ]);
55
+ });
@@ -5,10 +5,10 @@
5
5
  * their toolCallId but omit exact file lines already visible in an earlier
6
6
  * result, replacing them with references to the earlier tool call and source
7
7
  * location. Exact repeated list/glob/find results use a whole-result reference.
8
- * Any apply_patch/shell batch starts a fresh evidence epoch.
8
+ * Any file, repository, or shell mutation starts a fresh evidence epoch.
9
9
  */
10
10
 
11
- import { tokenizeDirectArgv } from '../tools/builtin/shell-direct-exe.mjs';
11
+ import { gitCommandMutates } from '../tools/builtin/git-command-policy.mjs';
12
12
 
13
13
  const EXACT_RESULT_TOOLS = new Set(['find', 'find_files', 'glob', 'list']);
14
14
 
@@ -156,70 +156,9 @@ function evidenceKey(row) {
156
156
  return `${row.path}\0${row.line}\0${row.content}`;
157
157
  }
158
158
 
159
- const READ_ONLY_GIT_COMMANDS = new Set([
160
- 'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
161
- 'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
162
- 'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
163
- 'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
164
- 'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
165
- 'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
166
- 'whatchanged',
167
- ]);
168
-
169
- function parsedGitCommand(command) {
170
- const tokens = tokenizeDirectArgv(command);
171
- if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) return null;
172
- let index = 1;
173
- while (index < tokens.length) {
174
- const token = tokens[index];
175
- if (token === '-C' || token === '-c' || token === '--git-dir' || token === '--work-tree' || token === '--namespace') {
176
- index += 2;
177
- continue;
178
- }
179
- if (/^-C.+/.test(token) || /^--(?:git-dir|work-tree|namespace)=/.test(token)
180
- || ['--no-pager', '--paginate', '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs'].includes(token)) {
181
- index++;
182
- continue;
183
- }
184
- break;
185
- }
186
- return index < tokens.length ? { command: tokens[index], args: tokens.slice(index + 1) } : null;
187
- }
188
-
189
- function firstGitArg(args) {
190
- return args.find((value) => value !== '--' && !value.startsWith('-')) || '';
191
- }
192
-
193
159
  function gitCallMutates(call) {
194
160
  let args = call?.arguments ?? call?.function?.arguments ?? {};
195
- if (typeof args === 'string') {
196
- try { args = JSON.parse(args); } catch { return true; }
197
- }
198
- const parsed = parsedGitCommand(args?.command);
199
- if (!parsed) return true;
200
- const operation = parsed.command;
201
- const action = firstGitArg(parsed.args);
202
- if (READ_ONLY_GIT_COMMANDS.has(operation)) return false;
203
- if (operation === 'reflog') return ['delete', 'expire'].includes(action);
204
- if (operation === 'branch' || operation === 'tag') {
205
- return parsed.args.length > 0 && !parsed.args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes', '--show-current'].includes(value));
206
- }
207
- if (operation === 'worktree') return !['', 'list'].includes(action);
208
- if (operation === 'stash') return !['list', 'show'].includes(action);
209
- if (operation === 'remote') return !['', 'show', 'get-url'].includes(action);
210
- if (operation === 'config') {
211
- return !parsed.args.some((value) => ['--list', '-l', '--get', '--get-all', '--get-regexp', '--show-origin', '--show-scope'].includes(value))
212
- && parsed.args.filter((value) => !value.startsWith('-')).length > 1;
213
- }
214
- if (operation === 'clean') return !parsed.args.some((value) => value === '-n' || value === '--dry-run');
215
- if (operation === 'bundle') return !['list-heads', 'verify'].includes(action);
216
- if (operation === 'notes') return !['', 'list', 'show'].includes(action);
217
- if (operation === 'replace') return parsed.args.length > 0 && !parsed.args.includes('--list');
218
- if (operation === 'sparse-checkout') return action !== 'list';
219
- if (operation === 'submodule') return !['', 'status', 'summary'].includes(action);
220
- if (operation === 'symbolic-ref') return parsed.args.filter((value) => !value.startsWith('-')).length > 1;
221
- if (operation === 'hash-object') return parsed.args.includes('-w') || parsed.args.includes('--stdin-paths');
222
- return true;
161
+ return gitCommandMutates(args);
223
162
  }
224
163
 
225
164
  function mutationBatch(toolCalls) {
@@ -271,6 +210,181 @@ function byteLength(value) {
271
210
  return Buffer.byteLength(String(value || ''), 'utf8');
272
211
  }
273
212
 
213
+ const PATH_ONLY_TOOLS = new Set(['find', 'find_files', 'glob']);
214
+
215
+ function pathOccurrenceForLine(toolName, line) {
216
+ const name = normalizeToolName(toolName);
217
+ if (PATH_ONLY_TOOLS.has(name)) {
218
+ if (!line || /^(?:\.\.\.|[[(#]|Error:)/.test(line)) return null;
219
+ return { path: line, start: 0, end: line.length };
220
+ }
221
+ if (name === 'list') {
222
+ const match = /^(.*)\t(?:file|dir|symlink|other)$/.exec(line);
223
+ return match?.[1]
224
+ ? { path: match[1], start: 0, end: match[1].length }
225
+ : null;
226
+ }
227
+ if (name === 'read') {
228
+ const match = /^(.+?)(?: \[[^\]]+\])? \[(?:ok|error)\](?: .*)?$/.exec(line);
229
+ return match?.[1]
230
+ ? { path: match[1], start: 0, end: match[1].length }
231
+ : null;
232
+ }
233
+ if (name === 'grep') {
234
+ const match = /^# (.+):\d+ \[lines \d+-\d+\]$/.exec(line);
235
+ return match?.[1]
236
+ ? { path: match[1], start: 2, end: 2 + match[1].length }
237
+ : null;
238
+ }
239
+ if (name === 'code_graph') {
240
+ const match = /^(.+):\d+-\d+:\d+ \([^)]+\)$/.exec(line);
241
+ return match?.[1]
242
+ ? { path: match[1], start: 0, end: match[1].length }
243
+ : null;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function projectProviderPathAliases(messages, { apply = true } = {}) {
249
+ const callById = new Map();
250
+ const occurrences = [];
251
+ let epoch = 0;
252
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
253
+ const message = messages[messageIndex];
254
+ if (!message || typeof message !== 'object') continue;
255
+ if (message.role === 'assistant' && Array.isArray(message.toolCalls)) {
256
+ if (mutationBatch(message.toolCalls)) epoch += 1;
257
+ for (const call of message.toolCalls) {
258
+ const id = call?.id || call?.toolCallId;
259
+ if (!id) continue;
260
+ callById.set(id, {
261
+ epoch,
262
+ name: call?.name || call?.function?.name,
263
+ });
264
+ }
265
+ continue;
266
+ }
267
+ if (message.role !== 'tool' || typeof message.content !== 'string'
268
+ || message.toolKind === 'error' || message.isError === true) continue;
269
+ const call = callById.get(message.toolCallId);
270
+ if (!call) continue;
271
+ const lines = message.content.split('\n');
272
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
273
+ const occurrence = pathOccurrenceForLine(call.name, lines[lineIndex]);
274
+ if (!occurrence?.path) continue;
275
+ occurrences.push({
276
+ ...occurrence,
277
+ epoch: call.epoch,
278
+ messageIndex,
279
+ lineIndex,
280
+ });
281
+ }
282
+ }
283
+
284
+ const groups = new Map();
285
+ for (const occurrence of occurrences) {
286
+ const key = `${occurrence.epoch}\0${occurrence.path}`;
287
+ if (!groups.has(key)) groups.set(key, []);
288
+ groups.get(key).push(occurrence);
289
+ }
290
+ const repeated = [...groups.values()]
291
+ .filter((group) => group.length > 1)
292
+ .sort((left, right) => (
293
+ left[0].messageIndex - right[0].messageIndex
294
+ || left[0].lineIndex - right[0].lineIndex
295
+ || left[0].start - right[0].start
296
+ ));
297
+ const aliasesByEpoch = new Map();
298
+ const dictionaries = new Map();
299
+ const edits = new Map();
300
+ let pathAliases = 0;
301
+ let reusedPathFacts = 0;
302
+ for (const group of repeated) {
303
+ const first = group[0];
304
+ const nextAlias = (aliasesByEpoch.get(first.epoch) || 0) + 1;
305
+ const alias = `p${nextAlias}`;
306
+ const dictionary = `[path-alias ${alias}=${JSON.stringify(first.path)}]`;
307
+ const originalBytes = group.reduce((sum, item) => sum + byteLength(item.path), 0);
308
+ const projectedBytes = byteLength(dictionary) + 1
309
+ + group.reduce((sum) => sum + byteLength(alias), 0);
310
+ if (projectedBytes >= originalBytes) continue;
311
+ aliasesByEpoch.set(first.epoch, nextAlias);
312
+ pathAliases += 1;
313
+ reusedPathFacts += group.length - 1;
314
+ if (!dictionaries.has(first.messageIndex)) dictionaries.set(first.messageIndex, []);
315
+ dictionaries.get(first.messageIndex).push(dictionary);
316
+ for (const occurrence of group) {
317
+ if (!edits.has(occurrence.messageIndex)) edits.set(occurrence.messageIndex, new Map());
318
+ const byLine = edits.get(occurrence.messageIndex);
319
+ if (!byLine.has(occurrence.lineIndex)) byLine.set(occurrence.lineIndex, []);
320
+ byLine.get(occurrence.lineIndex).push({
321
+ start: occurrence.start,
322
+ end: occurrence.end,
323
+ replacement: alias,
324
+ });
325
+ }
326
+ }
327
+ if (pathAliases === 0) {
328
+ return {
329
+ messages,
330
+ stats: {
331
+ pathFacts: occurrences.length,
332
+ pathAliases: 0,
333
+ reusedPathFacts: 0,
334
+ pathAliasBytesSaved: 0,
335
+ changedIndexes: [],
336
+ },
337
+ };
338
+ }
339
+
340
+ const changedIndexes = new Set([...dictionaries.keys(), ...edits.keys()]);
341
+ const projected = messages.slice();
342
+ let beforeBytes = 0;
343
+ let afterBytes = 0;
344
+ for (const messageIndex of changedIndexes) {
345
+ const message = messages[messageIndex];
346
+ beforeBytes += byteLength(message.content);
347
+ const lines = message.content.split('\n');
348
+ const byLine = edits.get(messageIndex);
349
+ if (byLine) {
350
+ for (const [lineIndex, lineEdits] of byLine) {
351
+ let line = lines[lineIndex];
352
+ for (const edit of [...lineEdits].sort((a, b) => b.start - a.start)) {
353
+ line = line.slice(0, edit.start) + edit.replacement + line.slice(edit.end);
354
+ }
355
+ lines[lineIndex] = line;
356
+ }
357
+ }
358
+ const dictionaryLines = dictionaries.get(messageIndex) || [];
359
+ const content = [...dictionaryLines, ...lines].join('\n');
360
+ afterBytes += byteLength(content);
361
+ if (apply) projected[messageIndex] = { ...message, content };
362
+ }
363
+ const saved = beforeBytes - afterBytes;
364
+ if (saved <= 0) {
365
+ return {
366
+ messages,
367
+ stats: {
368
+ pathFacts: occurrences.length,
369
+ pathAliases: 0,
370
+ reusedPathFacts: 0,
371
+ pathAliasBytesSaved: 0,
372
+ changedIndexes: [],
373
+ },
374
+ };
375
+ }
376
+ return {
377
+ messages: apply ? projected : messages,
378
+ stats: {
379
+ pathFacts: occurrences.length,
380
+ pathAliases,
381
+ reusedPathFacts,
382
+ pathAliasBytesSaved: saved,
383
+ changedIndexes: [...changedIndexes],
384
+ },
385
+ };
386
+ }
387
+
274
388
  export function projectProviderEvidence(messages, options = {}) {
275
389
  if (!Array.isArray(messages) || options.enabled === false) {
276
390
  return {
@@ -284,6 +398,10 @@ export function projectProviderEvidence(messages, options = {}) {
284
398
  changedToolResults: 0,
285
399
  exactResultRefs: 0,
286
400
  exactResultBytesSaved: 0,
401
+ pathFacts: 0,
402
+ pathAliases: 0,
403
+ reusedPathFacts: 0,
404
+ pathAliasBytesSaved: 0,
287
405
  },
288
406
  };
289
407
  }
@@ -292,6 +410,7 @@ export function projectProviderEvidence(messages, options = {}) {
292
410
  const callById = new Map();
293
411
  const seen = new Map();
294
412
  const seenExactResults = new Map();
413
+ const changedToolResultIndexes = new Set();
295
414
  let projected = null;
296
415
  const stats = {
297
416
  beforeBytes: 0,
@@ -302,6 +421,10 @@ export function projectProviderEvidence(messages, options = {}) {
302
421
  changedToolResults: 0,
303
422
  exactResultRefs: 0,
304
423
  exactResultBytesSaved: 0,
424
+ pathFacts: 0,
425
+ pathAliases: 0,
426
+ reusedPathFacts: 0,
427
+ pathAliasBytesSaved: 0,
305
428
  };
306
429
 
307
430
  for (let index = 0; index < messages.length; index += 1) {
@@ -341,6 +464,7 @@ export function projectProviderEvidence(messages, options = {}) {
341
464
  stats.exactResultRefs += 1;
342
465
  stats.exactResultBytesSaved += originalBytes - nextBytes;
343
466
  stats.changedToolResults += 1;
467
+ changedToolResultIndexes.add(index);
344
468
  stats.afterBytes += nextBytes;
345
469
  if (apply) {
346
470
  if (!projected) projected = messages.slice();
@@ -394,6 +518,7 @@ export function projectProviderEvidence(messages, options = {}) {
394
518
  stats.reusedRows += duplicates.length;
395
519
  stats.referenceGroups += references.length;
396
520
  stats.changedToolResults += 1;
521
+ changedToolResultIndexes.add(index);
397
522
  stats.afterBytes += nextBytes;
398
523
  if (apply) {
399
524
  if (!projected) projected = messages.slice();
@@ -401,6 +526,18 @@ export function projectProviderEvidence(messages, options = {}) {
401
526
  }
402
527
  }
403
528
 
529
+ const pathProjection = projectProviderPathAliases(projected || messages, { apply });
530
+ stats.pathFacts = pathProjection.stats.pathFacts;
531
+ stats.pathAliases = pathProjection.stats.pathAliases;
532
+ stats.reusedPathFacts = pathProjection.stats.reusedPathFacts;
533
+ stats.pathAliasBytesSaved = pathProjection.stats.pathAliasBytesSaved;
534
+ stats.afterBytes -= pathProjection.stats.pathAliasBytesSaved;
535
+ for (const index of pathProjection.stats.changedIndexes) changedToolResultIndexes.add(index);
536
+ stats.changedToolResults = changedToolResultIndexes.size;
537
+ if (apply && pathProjection.messages !== (projected || messages)) {
538
+ projected = pathProjection.messages;
539
+ }
540
+
404
541
  return { messages: projected || messages, stats };
405
542
  }
406
543
 
@@ -81,6 +81,60 @@ test('keeps tiny exact results when a reference would be larger', () => {
81
81
  assert.equal(projected.stats.exactResultRefs, 0);
82
82
  });
83
83
 
84
+ test('aliases repeated typed paths across same-turn glob and batch read results', () => {
85
+ const repeated = `src/${'deep/'.repeat(10)}feature.mjs`;
86
+ const first = result('glob_1', `${repeated}\nsrc/unique.mjs`);
87
+ const second = result('read_1', `read 1\n\n${repeated} [ok]\n1→export const value = 1;`);
88
+ const messages = [
89
+ {
90
+ role: 'assistant',
91
+ content: '',
92
+ toolCalls: [
93
+ { id: 'glob_1', name: 'glob', arguments: { pattern: '**/*.mjs' } },
94
+ { id: 'read_1', name: 'read', arguments: { file_path: '**/*.mjs' } },
95
+ ],
96
+ },
97
+ first,
98
+ second,
99
+ ];
100
+ const projected = projectProviderEvidence(messages);
101
+ const combined = `${projected.messages[1].content}\n${projected.messages[2].content}`;
102
+ assert.equal(projected.messages[1].toolCallId, 'glob_1');
103
+ assert.equal(projected.messages[2].toolCallId, 'read_1');
104
+ assert.equal(combined.split(repeated).length - 1, 1);
105
+ assert.match(projected.messages[1].content, /\[path-alias p1="/);
106
+ assert.match(projected.messages[1].content, /(?:^|\n)p1(?:\n|$)/);
107
+ assert.match(projected.messages[2].content, /(?:^|\n)p1 \[ok\](?:\n|$)/);
108
+ assert.equal(projected.stats.pathAliases, 1);
109
+ assert.equal(projected.stats.reusedPathFacts, 1);
110
+ assert.ok(projected.stats.pathAliasBytesSaved > 0);
111
+ assert.equal(messages[1], first);
112
+ assert.equal(messages[2], second);
113
+ });
114
+
115
+ test('keeps short repeated paths and resets path aliases across mutations', () => {
116
+ const shortMessages = [
117
+ call('glob_1', 'glob'),
118
+ result('glob_1', 'src/a.mjs'),
119
+ call('read_1', 'read'),
120
+ result('read_1', 'src/a.mjs [ok]\n1→a'),
121
+ ];
122
+ assert.equal(projectProviderEvidence(shortMessages).stats.pathAliases, 0);
123
+
124
+ const repeated = `src/${'nested/'.repeat(10)}a.mjs`;
125
+ const mutationMessages = [
126
+ call('glob_1', 'glob'),
127
+ result('glob_1', repeated),
128
+ call('patch_1', 'apply_patch'),
129
+ result('patch_1', 'ok'),
130
+ call('read_1', 'read'),
131
+ result('read_1', `${repeated} [ok]\n1→a`),
132
+ ];
133
+ const projected = projectProviderEvidence(mutationMessages);
134
+ assert.equal(projected.messages, mutationMessages);
135
+ assert.equal(projected.stats.pathAliases, 0);
136
+ });
137
+
84
138
  test('apply_patch, shell, and mutating git batches invalidate all earlier evidence', () => {
85
139
  const same = `same ${'s'.repeat(160)}`;
86
140
  const listing = Array.from({ length: 24 }, (_, index) => `src/item-${index}.mjs`).join('\n');
@@ -1,3 +1,5 @@
1
+ import { gitCommandMutates } from '../../tools/builtin/git-command-policy.mjs';
2
+
1
3
  // Tool-name classification + intra-turn signature helpers, extracted from
2
4
  // loop.mjs. These drive cross-turn read dedup, scoped caching, shell routing,
3
5
  // and duplicate-call detection. Strips the MCP prefix so direct calls and
@@ -11,9 +13,13 @@ export function _stripMcpPrefix(name) {
11
13
  export function _isReadTool(name) {
12
14
  return _stripMcpPrefix(name) === 'read';
13
15
  }
14
- export function _isMutationTool(name) {
16
+ export function _isMutationTool(name, args = null) {
17
+ const n = String(_stripMcpPrefix(name) || '').toLowerCase();
18
+ return n === 'apply_patch' || n === 'edit' || (n === 'git' && gitCommandMutates(args));
19
+ }
20
+ export function _isGitMutationTool(name, args = null) {
15
21
  const n = String(_stripMcpPrefix(name) || '').toLowerCase();
16
- return n === 'apply_patch' || n === 'edit';
22
+ return n === 'git' && gitCommandMutates(args);
17
23
  }
18
24
  export function _isEditTool(name) {
19
25
  return String(_stripMcpPrefix(name) || '').toLowerCase() === 'edit';
@@ -587,7 +587,7 @@ export async function processToolBatch(ctx) {
587
587
  clearScopedToolsForSession(sessionId);
588
588
  }
589
589
  }
590
- if (_isMutationTool(call.name)) {
590
+ if (_isMutationTool(call.name, call.arguments)) {
591
591
  epoch.mutation += 1;
592
592
  }
593
593
  // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
@@ -0,0 +1,107 @@
1
+ import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
2
+
3
+ const ALWAYS_READ = new Set([
4
+ 'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
5
+ 'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
6
+ 'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
7
+ 'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
8
+ 'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
9
+ 'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
10
+ 'whatchanged',
11
+ ]);
12
+
13
+ function commandHasShellSyntax(command) {
14
+ const text = String(command || '');
15
+ let quote = null;
16
+ for (let index = 0; index < text.length; index++) {
17
+ const char = text[index];
18
+ if (quote === "'") {
19
+ if (char === "'") quote = null;
20
+ continue;
21
+ }
22
+ if (quote === '"') {
23
+ if (char === '\\' && text[index + 1] === '"') { index++; continue; }
24
+ if (char === '"') { quote = null; continue; }
25
+ if (char === '$' || char === '`') return true;
26
+ continue;
27
+ }
28
+ if (char === "'" || char === '"') { quote = char; continue; }
29
+ if ('|&;<>()\n\r'.includes(char) || char === '$' || char === '`') return true;
30
+ }
31
+ return quote !== null;
32
+ }
33
+
34
+ export function gitActionOf(operation, args) {
35
+ const first = args.find((value) => value && !value.startsWith('-'));
36
+ if (operation === 'stash') return first || 'push';
37
+ if (operation === 'worktree') return first || 'list';
38
+ if (operation === 'remote') return first || 'list';
39
+ if (operation === 'reflog') return first || 'show';
40
+ return first || 'list';
41
+ }
42
+
43
+ export function gitPlanIsReadOnly(plan) {
44
+ const { operation, args } = plan;
45
+ if (operation === 'fsck' && args.includes('--lost-found')) return false;
46
+ if (ALWAYS_READ.has(operation)) return true;
47
+ if (operation === 'reflog') return !['delete', 'expire'].includes(gitActionOf(operation, args));
48
+ if (operation === 'stash') return ['list', 'show'].includes(gitActionOf(operation, args));
49
+ if (operation === 'worktree') return gitActionOf(operation, args) === 'list';
50
+ if (operation === 'remote') return args.length === 0 || args.includes('-v') || ['get-url', 'show'].includes(gitActionOf(operation, args));
51
+ if (operation === 'clean') return args.some((value) => value === '-n' || value === '--dry-run' || /^-[^-]*n/.test(value));
52
+ if (operation === 'bundle') return ['list-heads', 'verify'].includes(gitActionOf(operation, args));
53
+ if (operation === 'notes') return ['', 'list', 'show'].includes(gitActionOf(operation, args));
54
+ if (operation === 'replace') return args.length === 0 || args.includes('--list');
55
+ if (operation === 'sparse-checkout') return gitActionOf(operation, args) === 'list';
56
+ if (operation === 'submodule') return ['', 'status', 'summary'].includes(gitActionOf(operation, args));
57
+ if (operation === 'symbolic-ref') return args.filter((value) => !value.startsWith('-')).length <= 1;
58
+ if (operation === 'hash-object') return !args.includes('-w') && !args.includes('--stdin-paths');
59
+ if (operation === 'branch' || operation === 'tag') {
60
+ return args.length === 0 || args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes'].includes(value));
61
+ }
62
+ if (operation === 'config') {
63
+ const mutationFlags = new Set([
64
+ '--add', '--edit', '--rename-section', '--remove-section',
65
+ '--replace-all', '--unset', '--unset-all',
66
+ ]);
67
+ if (args.some((value) => mutationFlags.has(value))) return false;
68
+ const readFlag = args.some((value) => /^--(?:get|get-all|get-regexp|get-urlmatch|list|show-origin|show-scope)$/.test(value));
69
+ const positional = args.filter((value) => !value.startsWith('-'));
70
+ if (['set', 'unset', 'rename-section', 'remove-section'].includes(positional[0])) return false;
71
+ if (['get', 'get-all', 'get-regexp', 'get-urlmatch', 'list'].includes(positional[0])) return true;
72
+ return readFlag || positional.length <= 1;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function parsedGitOperation(command) {
78
+ if (commandHasShellSyntax(command)) return null;
79
+ const tokens = tokenizeDirectArgv(command);
80
+ if (!tokens?.length || !/(^|[\\/])git(?:\.exe)?$/i.test(tokens[0])) return null;
81
+ let index = 1;
82
+ while (index < tokens.length) {
83
+ const token = tokens[index];
84
+ if (token === '-C' || token === '-c' || token === '--config-env' || token === '--git-dir'
85
+ || token === '--work-tree' || token === '--namespace') {
86
+ index += 2;
87
+ continue;
88
+ }
89
+ if (/^-C.+/.test(token) || /^--(?:git-dir|work-tree|namespace|config-env)=/.test(token)
90
+ || ['--no-pager', '--paginate', '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs'].includes(token)) {
91
+ index++;
92
+ continue;
93
+ }
94
+ break;
95
+ }
96
+ const operation = String(tokens[index] || '').toLowerCase();
97
+ return operation ? { operation, args: tokens.slice(index + 1) } : null;
98
+ }
99
+
100
+ export function gitCommandMutates(value) {
101
+ let args = value;
102
+ if (typeof args === 'string') {
103
+ try { args = JSON.parse(args); } catch { args = { command: args }; }
104
+ }
105
+ const parsed = parsedGitOperation(args?.command);
106
+ return !parsed || !gitPlanIsReadOnly(parsed);
107
+ }
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { _isGitMutationTool, _isMutationTool } from '../../session/loop/tool-classify.mjs';
4
+ import { gitCommandMutates } from './git-command-policy.mjs';
5
+
6
+ test('git mutation policy is shared by orchestration and evidence projection', () => {
7
+ for (const command of ['git status', 'git log -5', 'git fsck --full', 'git clean -n', 'git config get user.name']) {
8
+ assert.equal(gitCommandMutates({ command }), false, command);
9
+ assert.equal(_isMutationTool('git', { command }), false, command);
10
+ assert.equal(_isGitMutationTool('git', { command }), false, command);
11
+ }
12
+ for (const command of [
13
+ 'git add --all',
14
+ 'git commit -m test',
15
+ 'git prune --expire=now',
16
+ 'git reflog delete HEAD@{1}',
17
+ 'git fsck --lost-found',
18
+ 'git config --unset user.name',
19
+ 'git config unset user.name',
20
+ ]) {
21
+ assert.equal(gitCommandMutates({ command }), true, command);
22
+ assert.equal(_isMutationTool('git', { command }), true, command);
23
+ assert.equal(_isGitMutationTool('git', { command }), true, command);
24
+ }
25
+ assert.equal(gitCommandMutates({ command: 'git status && git clean -fd' }), true);
26
+ assert.equal(_isMutationTool('apply_patch', {}), true);
27
+ });
@@ -1,4 +1,3 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { basename, resolve } from 'node:path';
3
2
  import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
4
3
  import { withBuiltinPathLocks } from './path-locks.mjs';
@@ -6,6 +5,8 @@ import { withAdvisoryLocks } from './advisory-lock.mjs';
6
5
  import { withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
7
6
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
8
7
  import { drainCodeGraphCache } from '../code-graph-state.mjs';
8
+ import { ensureNativeSpawnServer, tryNativeSpawn } from '../lib/native-spawn-client.mjs';
9
+ import { gitActionOf as actionOf, gitPlanIsReadOnly as isReadOnly } from './git-command-policy.mjs';
9
10
 
10
11
  const DEFAULT_TIMEOUT_MS = 120_000;
11
12
  const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
@@ -24,15 +25,6 @@ const SUPPORTED = new Set([
24
25
  'symbolic-ref', 'tag', 'update-ref', 'verify-commit', 'verify-pack',
25
26
  'verify-tag', 'whatchanged', 'worktree', 'write-tree',
26
27
  ]);
27
- const ALWAYS_READ = new Set([
28
- 'blame', 'cat-file', 'check-attr', 'check-ignore', 'check-ref-format',
29
- 'cherry', 'count-objects', 'describe', 'diff', 'diff-files', 'diff-index',
30
- 'diff-tree', 'for-each-ref', 'fsck', 'grep', 'help', 'log', 'ls-files',
31
- 'ls-remote', 'ls-tree', 'merge-base', 'merge-tree', 'name-rev',
32
- 'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-branch',
33
- 'show-ref', 'status', 'verify-commit', 'verify-pack', 'verify-tag',
34
- 'whatchanged',
35
- ]);
36
28
  const PUSH_NOISE = [
37
29
  'Enumerating objects:', 'Counting objects:', 'Compressing objects:',
38
30
  'Writing objects:', 'Delta compression using', 'Total ',
@@ -155,11 +147,14 @@ function parseCommand(command, workDir) {
155
147
  return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
156
148
  }
157
149
 
158
- function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES } = {}) {
159
- return new Promise((done) => {
160
- let child;
161
- try {
162
- child = spawn(program, argv, {
150
+ async function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES } = {}) {
151
+ let child;
152
+ try {
153
+ await ensureNativeSpawnServer();
154
+ const native = tryNativeSpawn({
155
+ shell: program,
156
+ argv,
157
+ spawnOptions: {
163
158
  cwd,
164
159
  env: {
165
160
  ...process.env,
@@ -169,14 +164,15 @@ function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES }
169
164
  GIT_SEQUENCE_EDITOR: 'true',
170
165
  LC_ALL: 'C',
171
166
  },
172
- shell: false,
173
- windowsHide: true,
174
- stdio: ['ignore', 'pipe', 'pipe'],
175
- });
176
- } catch (error) {
177
- done({ exitCode: null, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), error, timedOut: false, aborted: false, overflow: false });
178
- return;
179
- }
167
+ outputLimit: maxBytes,
168
+ },
169
+ });
170
+ if (!native?.child) throw Object.assign(new Error('verified native spawn server unavailable'), { code: 'NATIVE_SPAWN_UNAVAILABLE' });
171
+ child = native.child;
172
+ } catch (error) {
173
+ return { exitCode: null, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), error, timedOut: false, aborted: false, overflow: false };
174
+ }
175
+ return new Promise((done) => {
180
176
  const stdout = [], stderr = [];
181
177
  let bytes = 0, timedOut = false, aborted = false, overflow = false, settled = false;
182
178
  const stop = () => { try { child.kill('SIGKILL'); } catch {} };
@@ -228,42 +224,6 @@ function runGit(plan, argv, options = {}) {
228
224
  return runProcess('git', [...plan.globalArgs, ...argv], { cwd: plan.cwd, signal: options.signal });
229
225
  }
230
226
 
231
- function actionOf(operation, args) {
232
- const first = args.find((value) => value && !value.startsWith('-'));
233
- if (operation === 'stash') return first || 'push';
234
- if (operation === 'worktree') return first || 'list';
235
- if (operation === 'remote') return first || 'list';
236
- if (operation === 'reflog') return first || 'show';
237
- return first || 'list';
238
- }
239
-
240
- function isReadOnly(plan) {
241
- const { operation, args } = plan;
242
- if (ALWAYS_READ.has(operation)) return true;
243
- if (operation === 'reflog') return !['delete', 'expire'].includes(actionOf(operation, args));
244
- if (operation === 'stash') return ['list', 'show'].includes(actionOf(operation, args));
245
- if (operation === 'worktree') return actionOf(operation, args) === 'list';
246
- if (operation === 'remote') return args.length === 0 || args.includes('-v') || ['get-url', 'show'].includes(actionOf(operation, args));
247
- if (operation === 'clean') return args.some((value) => value === '-n' || value === '--dry-run' || /^-[^-]*n/.test(value));
248
- if (operation === 'bundle') return ['list-heads', 'verify'].includes(actionOf(operation, args));
249
- if (operation === 'notes') return ['', 'list', 'show'].includes(actionOf(operation, args));
250
- if (operation === 'replace') return args.length === 0 || args.includes('--list');
251
- if (operation === 'sparse-checkout') return actionOf(operation, args) === 'list';
252
- if (operation === 'submodule') return ['', 'status', 'summary'].includes(actionOf(operation, args));
253
- if (operation === 'symbolic-ref') return args.filter((value) => !value.startsWith('-')).length <= 1;
254
- if (operation === 'hash-object') return !args.includes('-w') && !args.includes('--stdin-paths');
255
- if (operation === 'branch' || operation === 'tag') {
256
- if (args.length === 0 || args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes'].includes(value))) return true;
257
- return false;
258
- }
259
- if (operation === 'config') {
260
- const readFlag = args.some((value) => /^--(?:get|get-all|get-regexp|get-urlmatch|list|show-origin|show-scope)$/.test(value));
261
- const positional = args.filter((value) => !value.startsWith('-'));
262
- return readFlag || positional.length <= 1;
263
- }
264
- return false;
265
- }
266
-
267
227
  function destructiveReason(plan) {
268
228
  const { operation, args } = plan;
269
229
  if (operation === 'reset' && args.includes('--hard')) return 'git reset --hard';
@@ -544,8 +504,50 @@ async function resolveRepo(plan, signal) {
544
504
  return succeeded(result) ? cleanText(result.stdout) : null;
545
505
  }
546
506
 
547
- async function executeCreation(plan, limit, signal) {
548
- return withBuiltinPathLocks([plan.cwd], () => withAdvisoryLocks([plan.cwd], async () => {
507
+ function localizeConfigPlan(plan) {
508
+ if (plan.operation !== 'config') return plan;
509
+ const external = plan.args.find((value) => ['--global', '--system', '--file', '-f'].includes(value)
510
+ || value.startsWith('--file=')
511
+ || (/^-f./.test(value) && !value.startsWith('--')));
512
+ if (external) throw new Error(`git config ${external} is outside the local repository scope`);
513
+ if (plan.args.some((value) => value === '--local' || value === '--worktree')) return plan;
514
+ return { ...plan, args: ['--local', ...plan.args] };
515
+ }
516
+
517
+ function optionFreePositionals(args) {
518
+ const takesValue = new Set([
519
+ '-b', '--branch', '-c', '--config', '--depth', '-j', '--jobs', '-o',
520
+ '--origin', '--reference', '--reference-if-able', '--separate-git-dir',
521
+ '--template', '-u', '--upload-pack', '--filter', '--server-option',
522
+ '--shallow-since', '--shallow-exclude', '--bundle-uri', '--revision',
523
+ '--ref-format', '--object-format', '--initial-branch',
524
+ ]);
525
+ const out = [];
526
+ for (let index = 0; index < args.length; index++) {
527
+ const value = args[index];
528
+ if (value === '--') {
529
+ out.push(...args.slice(index + 1));
530
+ break;
531
+ }
532
+ if (takesValue.has(value)) { index++; continue; }
533
+ if (value.startsWith('-')) continue;
534
+ out.push(value);
535
+ }
536
+ return out;
537
+ }
538
+
539
+ function creationTarget(plan) {
540
+ const positional = optionFreePositionals(plan.args);
541
+ if (plan.operation === 'init') return resolve(plan.cwd, positional.at(-1) || '.');
542
+ if (plan.operation !== 'clone') return plan.cwd;
543
+ if (positional.length >= 2) return resolve(plan.cwd, positional.at(-1));
544
+ const source = String(positional[0] || '').replace(/[\\/]+$/, '');
545
+ const leaf = basename(source.includes(':') ? source.slice(source.lastIndexOf(':') + 1) : source).replace(/\.git$/i, '') || 'repo';
546
+ return resolve(plan.cwd, leaf);
547
+ }
548
+
549
+ async function executeCreation(plan, target, limit, signal) {
550
+ return withBuiltinPathLocks([target], () => withAdvisoryLocks([target], async () => {
549
551
  const result = await runGit(plan, [plan.operation, ...plan.args], { signal });
550
552
  if (!succeeded(result)) return commandFailure(plan, result);
551
553
  invalidateBuiltinResultCache();
@@ -557,7 +559,7 @@ async function executeCreation(plan, limit, signal) {
557
559
  export async function executeGitTool(input, workDir, options = {}) {
558
560
  if (!input || typeof input !== 'object' || Array.isArray(input)) return fail('git requires an arguments object');
559
561
  let plan;
560
- try { plan = parseCommand(input.command, workDir); }
562
+ try { plan = localizeConfigPlan(parseCommand(input.command, workDir)); }
561
563
  catch (error) { return fail(error.message); }
562
564
  if (plan.operation === 'archive' && !plan.args.some((value) => value === '-o' || value === '--output' || value.startsWith('--output='))) {
563
565
  return fail('git archive requires -o/--output; binary stdout is not returned');
@@ -568,7 +570,8 @@ export async function executeGitTool(input, workDir, options = {}) {
568
570
  if (reason && input.confirm !== true) return fail(`${reason} requires confirm:true`);
569
571
  const signal = options?.signal || options?.abortSignal || null;
570
572
  if (plan.operation === 'init' || plan.operation === 'clone') {
571
- return withGitRepoWriteLock(plan.cwd, () => executeCreation(plan, limit, signal));
573
+ const target = creationTarget(plan);
574
+ return withGitRepoWriteLock(target, () => executeCreation(plan, target, limit, signal), { signal });
572
575
  }
573
576
  const repo = await resolveRepo(plan, signal);
574
577
  if (!repo) {
@@ -581,7 +584,7 @@ export async function executeGitTool(input, workDir, options = {}) {
581
584
  const result = await runGit(plan, prepared.argv, { signal });
582
585
  if (!succeeded(result)) return commandFailure(plan, result);
583
586
  return ok(formatRead(prepared, cleanText(result.stdout), cleanText(result.stderr), limit));
584
- });
587
+ }, { signal });
585
588
  }
586
589
  return withGitRepoWriteLock(repo, () => withBuiltinPathLocks([repo], () => withAdvisoryLocks([repo], async () => {
587
590
  const before = await statusSnapshot(repo, signal);
@@ -591,5 +594,7 @@ export async function executeGitTool(input, workDir, options = {}) {
591
594
  drainCodeGraphCache();
592
595
  if (!succeeded(result)) return `${commandFailure(plan, result)}\n${JSON.stringify({ status: statusDelta(before, after, limit) })}`;
593
596
  return ok({ ...mutationData(plan, cleanText(result.stdout), cleanText(result.stderr), limit), status: statusDelta(before, after, limit) });
594
- })));
597
+ })), { signal });
595
598
  }
599
+
600
+ export const _gitCommandInternals = { creationTarget, localizeConfigPlan, parseCommand };
@@ -4,7 +4,7 @@ import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import test from 'node:test';
7
- import { executeGitTool, GIT_TOOL_DEF } from './git-command-tool.mjs';
7
+ import { executeGitTool, GIT_TOOL_DEF, _gitCommandInternals } from './git-command-tool.mjs';
8
8
 
9
9
  function parseOk(result) {
10
10
  assert.doesNotMatch(String(result), /^Error:/, String(result));
@@ -29,6 +29,14 @@ test('git command tool preserves shell syntax, compacts output, and gates destru
29
29
  assert.equal(parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root)).summary, 'initialized');
30
30
  parseOk(await git(repo, 'config user.name "Mixdog Test"'));
31
31
  parseOk(await git(repo, 'config user.email mixdog@example.invalid'));
32
+ assert.match(String(await git(repo, 'config --global user.name')), /outside the local repository scope/);
33
+ assert.match(String(await git(repo, 'config --system user.name')), /outside the local repository scope/);
34
+ assert.match(String(await git(repo, 'config --file ..\/outside user.name')), /outside the local repository scope/);
35
+ assert.match(String(await git(repo, 'config -f..\/outside user.name')), /outside the local repository scope/);
36
+ assert.equal(
37
+ _gitCommandInternals.creationTarget(_gitCommandInternals.parseCommand(`git clone origin ${quote(join(root, 'target'))}`, root)),
38
+ join(root, 'target'),
39
+ );
32
40
 
33
41
  writeFileSync(join(repo, 'base.txt'), 'base\n');
34
42
  const staged = parseOk(await git(repo, 'add --all'));
@@ -26,12 +26,16 @@ function drain(key, state) {
26
26
  if (state.readers === 0 && state.queue[0].mode === 'write') {
27
27
  const entry = state.queue.shift();
28
28
  state.writer = true;
29
+ entry.granted = true;
30
+ entry.signal?.removeEventListener?.('abort', entry.onAbort);
29
31
  entry.resolve(releaseFor(key, state, 'write'));
30
32
  return;
31
33
  }
32
34
  while (state.queue[0]?.mode === 'read' && !state.writer) {
33
35
  const entry = state.queue.shift();
34
36
  state.readers++;
37
+ entry.granted = true;
38
+ entry.signal?.removeEventListener?.('abort', entry.onAbort);
35
39
  entry.resolve(releaseFor(key, state, 'read'));
36
40
  }
37
41
  }
@@ -47,28 +51,46 @@ function releaseFor(key, state, mode) {
47
51
  };
48
52
  }
49
53
 
50
- function acquire(repo, mode) {
54
+ function abortError(signal) {
55
+ if (signal?.reason instanceof Error) return signal.reason;
56
+ return Object.assign(new Error('git repository lock aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
57
+ }
58
+
59
+ function acquire(repo, mode, signal) {
60
+ if (signal?.aborted) return Promise.reject(abortError(signal));
51
61
  const key = keyFor(repo);
52
62
  const state = stateFor(key);
53
- return new Promise((resolveLock) => {
54
- state.queue.push({ mode, resolve: resolveLock });
63
+ return new Promise((resolveLock, rejectLock) => {
64
+ const entry = { mode, resolve: resolveLock, reject: rejectLock, signal, onAbort: null, granted: false };
65
+ entry.onAbort = () => {
66
+ if (entry.granted) return;
67
+ const index = state.queue.indexOf(entry);
68
+ if (index >= 0) state.queue.splice(index, 1);
69
+ signal?.removeEventListener?.('abort', entry.onAbort);
70
+ rejectLock(abortError(signal));
71
+ drain(key, state);
72
+ };
73
+ state.queue.push(entry);
74
+ signal?.addEventListener?.('abort', entry.onAbort, { once: true });
55
75
  drain(key, state);
56
76
  });
57
77
  }
58
78
 
59
- async function withLock(repo, mode, fn) {
60
- const release = await acquire(repo, mode);
79
+ async function withLock(repo, mode, fn, options = {}) {
80
+ const signal = options?.signal || null;
81
+ const release = await acquire(repo, mode, signal);
61
82
  try {
83
+ if (signal?.aborted) throw abortError(signal);
62
84
  return await fn();
63
85
  } finally {
64
86
  release();
65
87
  }
66
88
  }
67
89
 
68
- export function withGitRepoReadLock(repo, fn) {
69
- return withLock(repo, 'read', fn);
90
+ export function withGitRepoReadLock(repo, fn, options = {}) {
91
+ return withLock(repo, 'read', fn, options);
70
92
  }
71
93
 
72
- export function withGitRepoWriteLock(repo, fn) {
73
- return withLock(repo, 'write', fn);
94
+ export function withGitRepoWriteLock(repo, fn, options = {}) {
95
+ return withLock(repo, 'write', fn, options);
74
96
  }
@@ -62,3 +62,22 @@ test('git repo writers on different repositories run in parallel', async () => {
62
62
  hold.release();
63
63
  await Promise.all([first, second]);
64
64
  });
65
+
66
+ test('a queued git repo lock aborts without running or blocking later work', async () => {
67
+ const repo = `repo-abort-${Date.now()}-${Math.random()}`;
68
+ const hold = gate();
69
+ const writer = withGitRepoWriteLock(repo, () => hold.promise);
70
+ await Promise.resolve();
71
+
72
+ const controller = new AbortController();
73
+ let ran = false;
74
+ const queued = withGitRepoReadLock(repo, async () => { ran = true; }, { signal: controller.signal });
75
+ controller.abort();
76
+ await assert.rejects(queued, /abort/i);
77
+ assert.equal(ran, false);
78
+
79
+ hold.release();
80
+ await writer;
81
+ await withGitRepoReadLock(repo, async () => { ran = true; });
82
+ assert.equal(ran, true);
83
+ });