@mnemonik/shared 7.120.0 → 7.120.2
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/gitEditDetector.ts
CHANGED
|
@@ -47,12 +47,38 @@
|
|
|
47
47
|
* tree clean by the time it is observed, so the dirty diff sees nothing.
|
|
48
48
|
* HEAD is recorded per root at every observation; only commits made since
|
|
49
49
|
* the last observation count, and never a merge.
|
|
50
|
+
*
|
|
51
|
+
* Three more (2026-10-07), found by a session whose push was gated a dozen
|
|
52
|
+
* times for edits it never made:
|
|
53
|
+
* - A nested repository or worktree directory is not an edit. `git status`
|
|
54
|
+
* lists one as a single `dir/` entry when its parent does not ignore it, so
|
|
55
|
+
* creating a worktree under the checkout counted as a file edit.
|
|
56
|
+
* - A shell call that runs a git command importing other content (merge,
|
|
57
|
+
* cherry-pick, rebase, pull, am, revert, reset, stash pop/apply) credits
|
|
58
|
+
* nothing: what it leaves dirty or commits was written elsewhere.
|
|
59
|
+
* - A pending report for a path whose repository directory no longer exists
|
|
60
|
+
* (a removed worktree) is dropped, not re-sent: the files are gone because
|
|
61
|
+
* the checkout was removed, not because this session deleted them.
|
|
62
|
+
*
|
|
63
|
+
* Each report also carries where the edit happened (`editScope`): the git
|
|
64
|
+
* worktree and repository of the file, or a mark that the file lies outside
|
|
65
|
+
* the project's repository (/tmp, another repository, a `.git` directory). The
|
|
66
|
+
* server counts only project edits, and gates a push on the uncheckpointed
|
|
67
|
+
* files it carries (`describeHandoffs`).
|
|
50
68
|
*/
|
|
51
69
|
|
|
52
70
|
import { execFileSync } from 'node:child_process';
|
|
53
71
|
import { createHash, randomUUID } from 'node:crypto';
|
|
54
|
-
import {
|
|
55
|
-
|
|
72
|
+
import {
|
|
73
|
+
mkdirSync,
|
|
74
|
+
readFileSync,
|
|
75
|
+
realpathSync,
|
|
76
|
+
renameSync,
|
|
77
|
+
statSync,
|
|
78
|
+
unlinkSync,
|
|
79
|
+
writeFileSync,
|
|
80
|
+
} from 'node:fs';
|
|
81
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
56
82
|
import { performance } from 'node:perf_hooks';
|
|
57
83
|
|
|
58
84
|
/** Bounded so a pathological repo cannot stall the hook's PostToolUse turn. */
|
|
@@ -121,13 +147,28 @@ export function listGitDirtyPaths(cwd: string): { root: string; paths: string[]
|
|
|
121
147
|
// "XY <path>"; a rename/copy entry is followed by the ORIGINAL path as its
|
|
122
148
|
// own NUL token - consume it so it is never misread as a status entry.
|
|
123
149
|
const statusCode = entry.slice(0, 2);
|
|
124
|
-
paths.push(join(root, entry.slice(3)));
|
|
125
150
|
if (statusCode.startsWith('R') || statusCode.startsWith('C')) i++;
|
|
151
|
+
// With --untracked-files=all every untracked FILE is listed on its own; the
|
|
152
|
+
// only entry that ends in a slash is a nested repository or worktree, which
|
|
153
|
+
// git never descends into. Its appearance is a checkout, not an edit.
|
|
154
|
+
if (entry.endsWith('/')) continue;
|
|
155
|
+
paths.push(join(root, entry.slice(3)));
|
|
126
156
|
if (paths.length > MAX_SNAPSHOT_PATHS) return null;
|
|
127
157
|
}
|
|
128
158
|
return { root, paths };
|
|
129
159
|
}
|
|
130
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Git commands that bring content written elsewhere into the working tree.
|
|
163
|
+
* What a merge (squash included), cherry-pick, rebase, pull, am, revert, reset
|
|
164
|
+
* or stash pop/apply leaves dirty or commits is not this session's edit.
|
|
165
|
+
* Classifying the command is coarse on purpose: a shell call that also edits a
|
|
166
|
+
* file in the same command is credited nothing, which errs toward silence
|
|
167
|
+
* (the next edit to that file is still credited).
|
|
168
|
+
*/
|
|
169
|
+
const GIT_CONTENT_IMPORT_RE =
|
|
170
|
+
/\bgit\s+(?:-C\s+\S+\s+)?(?:merge|cherry-pick|rebase|pull|am|revert|reset|stash\s+(?:pop|apply))\b/;
|
|
171
|
+
|
|
131
172
|
/** HEAD of the repo at cwd, or undefined before the first commit. */
|
|
132
173
|
function currentHead(cwd: string): string | undefined {
|
|
133
174
|
return (
|
|
@@ -254,6 +295,11 @@ export function diffGitDirtySnapshot(snapshotFile: string, cwd: string, command
|
|
|
254
295
|
writeRoot(snapshotFile, current.root, { paths: current.paths, head });
|
|
255
296
|
return [];
|
|
256
297
|
}
|
|
298
|
+
if (GIT_CONTENT_IMPORT_RE.test(command)) {
|
|
299
|
+
// Absorb: everything dirty now is known, nothing is credited.
|
|
300
|
+
writeRoot(snapshotFile, current.root, { paths: current.paths, head });
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
257
303
|
const known = new Set(baseline.paths);
|
|
258
304
|
const additions = current.paths.filter((path) => !known.has(path));
|
|
259
305
|
if (head && head !== baseline.head && /\bgit\s+commit\b/.test(command)) {
|
|
@@ -286,6 +332,526 @@ export function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]
|
|
|
286
332
|
}
|
|
287
333
|
}
|
|
288
334
|
|
|
335
|
+
// - Where an edit happened -
|
|
336
|
+
//
|
|
337
|
+
// A session's hooks report every file the agent writes, wherever it is: a
|
|
338
|
+
// scratch note in /tmp, a file in another repository, a file in a subagent's own
|
|
339
|
+
// worktree. Only edits to the project's repository are project work, and a
|
|
340
|
+
// handoff (git push) hands off only the worktree it runs in. The host knows
|
|
341
|
+
// the git topology; the server does not. So each report says where the edit
|
|
342
|
+
// happened, and the server counts accordingly.
|
|
343
|
+
|
|
344
|
+
interface GitLocation {
|
|
345
|
+
/** The worktree root: the nearest directory holding `.git`. */
|
|
346
|
+
root: string;
|
|
347
|
+
/** The repository's common directory: the same for every worktree of one repository. */
|
|
348
|
+
commonDir: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* One process handles one hook event, and an event reports up to 25 paths,
|
|
353
|
+
* mostly in the same few directories: remember each directory's answer.
|
|
354
|
+
*/
|
|
355
|
+
const gitLocationCache = new Map<string, GitLocation | 'none' | null>();
|
|
356
|
+
|
|
357
|
+
function canonicalPath(path: string): string {
|
|
358
|
+
try {
|
|
359
|
+
return realpathSync(path);
|
|
360
|
+
} catch {
|
|
361
|
+
return path;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The directory a `.git` FILE (a linked worktree or submodule) points at, or null. */
|
|
366
|
+
function gitDirFromFile(dotGitFile: string, root: string): string | null {
|
|
367
|
+
try {
|
|
368
|
+
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGitFile, 'utf8'));
|
|
369
|
+
return match?.[1] ? resolve(root, match[1].trim()) : null;
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A linked worktree's git dir names its repository in `commondir`; otherwise it is its own. */
|
|
376
|
+
function commonDirOf(gitDir: string): string {
|
|
377
|
+
try {
|
|
378
|
+
const relative = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
|
|
379
|
+
return relative ? resolve(gitDir, relative) : gitDir;
|
|
380
|
+
} catch {
|
|
381
|
+
return gitDir;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Git skips a `.git` that is not a repository (a stray empty `/tmp/.git` is
|
|
387
|
+
* real): it must hold `HEAD`, and its repository must hold `objects`.
|
|
388
|
+
*/
|
|
389
|
+
function isGitDir(gitDir: string): boolean {
|
|
390
|
+
try {
|
|
391
|
+
return (
|
|
392
|
+
statSync(join(gitDir, 'HEAD')).isFile() &&
|
|
393
|
+
statSync(join(commonDirOf(gitDir), 'objects')).isDirectory()
|
|
394
|
+
);
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The git location of a directory, found the way git finds it: the nearest
|
|
402
|
+
* ancestor holding a valid `.git`. A `.git` directory is the repository; a `.git`
|
|
403
|
+
* file (linked worktree) points at a git dir whose `commondir` names the
|
|
404
|
+
* repository. 'none' when no ancestor holds `.git`; null when the file system
|
|
405
|
+
* would not say (permissions), which callers treat as unknown, never as
|
|
406
|
+
* "outside". Read from the file system rather than by running git: a hook
|
|
407
|
+
* reports up to 25 paths inside a time budget, and a process per path would
|
|
408
|
+
* spend it.
|
|
409
|
+
*/
|
|
410
|
+
function gitLocation(dir: string): GitLocation | 'none' | null {
|
|
411
|
+
const cached = gitLocationCache.get(dir);
|
|
412
|
+
if (cached !== undefined) return cached;
|
|
413
|
+
let result: GitLocation | 'none' | null = 'none';
|
|
414
|
+
for (let current = dir; ;) {
|
|
415
|
+
const dotGit = join(current, '.git');
|
|
416
|
+
let stat: ReturnType<typeof statSync> | undefined;
|
|
417
|
+
try {
|
|
418
|
+
stat = statSync(dotGit);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
421
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
422
|
+
result = null;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (stat?.isDirectory() && isGitDir(dotGit)) {
|
|
427
|
+
result = { root: current, commonDir: canonicalPath(dotGit) };
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
if (stat?.isFile()) {
|
|
431
|
+
const gitDir = gitDirFromFile(dotGit, current);
|
|
432
|
+
if (gitDir && isGitDir(gitDir)) {
|
|
433
|
+
result = { root: current, commonDir: canonicalPath(commonDirOf(gitDir)) };
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const parent = dirname(current);
|
|
438
|
+
if (parent === current) break;
|
|
439
|
+
current = parent;
|
|
440
|
+
}
|
|
441
|
+
gitLocationCache.set(dir, result);
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The deepest existing directory at or above `path` (a deleted file's parent may be gone). */
|
|
446
|
+
function nearestExistingDirectory(path: string): string {
|
|
447
|
+
let current = path;
|
|
448
|
+
for (;;) {
|
|
449
|
+
try {
|
|
450
|
+
if (statSync(current).isDirectory()) return current;
|
|
451
|
+
} catch {
|
|
452
|
+
// Missing: try the parent.
|
|
453
|
+
}
|
|
454
|
+
const parent = dirname(current);
|
|
455
|
+
if (parent === current) return current;
|
|
456
|
+
current = parent;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isWithin(path: string, dir: string): boolean {
|
|
461
|
+
const base = dir.endsWith('/') ? dir : `${dir}/`;
|
|
462
|
+
return path === dir || path.startsWith(base);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Where a reported edit happened, as sent to track-ide-edit. */
|
|
466
|
+
export interface EditScope {
|
|
467
|
+
/** The git worktree root that holds the file. Absent when unknown or not in git. */
|
|
468
|
+
editRoot?: string;
|
|
469
|
+
/**
|
|
470
|
+
* The repository (git common directory) that holds the file: the same for
|
|
471
|
+
* every worktree of one repository, so an edit made in a subagent's worktree
|
|
472
|
+
* still matches a push of the merged result from the main checkout.
|
|
473
|
+
*/
|
|
474
|
+
editRepo?: string;
|
|
475
|
+
/** The file is not project work: outside the project's repository, or inside `.git`. */
|
|
476
|
+
outsideProject?: true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Where `filePath` lies relative to the project the agent works in.
|
|
481
|
+
*
|
|
482
|
+
* `anchorDir` is the project directory: the host's project root when it has
|
|
483
|
+
* one (Claude Code's CLAUDE_PROJECT_DIR), else the event's cwd. A file is
|
|
484
|
+
* project work when it is in the same git repository as the anchor (any of
|
|
485
|
+
* its worktrees), or, when the anchor is not in git, inside the anchor.
|
|
486
|
+
* Anything that cannot be determined is reported as unknown (no claim), so an
|
|
487
|
+
* unreadable directory never hides an edit.
|
|
488
|
+
*/
|
|
489
|
+
export function editScope(filePath: string, cwd: string, anchorDir: string = cwd): EditScope {
|
|
490
|
+
if (!filePath) return {};
|
|
491
|
+
const absolute = isAbsolute(filePath) ? filePath : join(cwd, filePath);
|
|
492
|
+
if (/(?:^|\/)\.git(?:\/|$)/.test(absolute)) return { outsideProject: true };
|
|
493
|
+
const file = gitLocation(nearestExistingDirectory(dirname(absolute)));
|
|
494
|
+
const anchor = anchorDir ? gitLocation(anchorDir) : null;
|
|
495
|
+
const editRoot = file && file !== 'none' ? { editRoot: file.root, editRepo: file.commonDir } : {};
|
|
496
|
+
if (file === null || anchor === null) return editRoot;
|
|
497
|
+
if (anchor === 'none') {
|
|
498
|
+
return isWithin(absolute, anchorDir) ? editRoot : { outsideProject: true };
|
|
499
|
+
}
|
|
500
|
+
if (file === 'none' || file.commonDir !== anchor.commonDir) return { outsideProject: true };
|
|
501
|
+
return editRoot;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// - Which shell commands hand work off, and what they hand off -
|
|
505
|
+
//
|
|
506
|
+
// The server gates a handoff (git push, PR, deploy) on uncheckpointed work.
|
|
507
|
+
// It used to match handoff words anywhere in the command text and count every
|
|
508
|
+
// edit the session had ever reported. One parser, shared by the server and the
|
|
509
|
+
// hosts, now says which commands hand work off; the host, which has the
|
|
510
|
+
// repository, says what a push carries.
|
|
511
|
+
|
|
512
|
+
const SHELL_TOOLS = new Set(['bash', 'shell', 'terminal', 'run_terminal_command']);
|
|
513
|
+
|
|
514
|
+
/** One handoff in a shell command. */
|
|
515
|
+
export interface HandoffTarget {
|
|
516
|
+
/** Where it runs: null means the event's cwd. */
|
|
517
|
+
directory: string | null;
|
|
518
|
+
/** For a git push: the revisions whose new commits it sends. Absent for other handoffs. */
|
|
519
|
+
revisions?: string[];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Here-document bodies are data (a script being written, a commit message), never commands. */
|
|
523
|
+
function stripHereDocuments(command: string): string {
|
|
524
|
+
const kept: string[] = [];
|
|
525
|
+
let terminator: { word: string; stripTabs: boolean } | null = null;
|
|
526
|
+
for (const line of command.split('\n')) {
|
|
527
|
+
if (terminator) {
|
|
528
|
+
const candidate = terminator.stripTabs ? line.replace(/^\t+/, '') : line;
|
|
529
|
+
if (candidate.trim() === terminator.word) terminator = null;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
kept.push(line);
|
|
533
|
+
const opener = /(?<!<)<<(?!<)(-?)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(line);
|
|
534
|
+
if (opener) terminator = { word: opener[3] ?? '', stripTabs: opener[1] === '-' };
|
|
535
|
+
}
|
|
536
|
+
return kept.join('\n');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Placeholder delimiters for quoted text: private-use code points, never typed in a command. */
|
|
540
|
+
const QUOTE_OPEN = '';
|
|
541
|
+
const QUOTE_CLOSE = '';
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Replace every quoted string with an opaque placeholder so handoff words
|
|
545
|
+
* inside quotes (a grep pattern, a commit message) are never read as commands.
|
|
546
|
+
* The quoted text is kept so a quoted directory or ref can be restored.
|
|
547
|
+
*/
|
|
548
|
+
function maskQuotes(text: string): { skeleton: string; quoted: string[] } {
|
|
549
|
+
const quoted: string[] = [];
|
|
550
|
+
let skeleton = '';
|
|
551
|
+
let i = 0;
|
|
552
|
+
while (i < text.length) {
|
|
553
|
+
const char = text[i];
|
|
554
|
+
if (char === '\\' && i + 1 < text.length) {
|
|
555
|
+
skeleton += text.slice(i, i + 2);
|
|
556
|
+
i += 2;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (char === "'" || char === '"') {
|
|
560
|
+
let j = i + 1;
|
|
561
|
+
let body = '';
|
|
562
|
+
while (j < text.length && text[j] !== char) {
|
|
563
|
+
if (char === '"' && text[j] === '\\' && j + 1 < text.length) {
|
|
564
|
+
body += text[j + 1];
|
|
565
|
+
j += 2;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
body += text[j];
|
|
569
|
+
j += 1;
|
|
570
|
+
}
|
|
571
|
+
quoted.push(body);
|
|
572
|
+
skeleton += `${QUOTE_OPEN}${quoted.length - 1}${QUOTE_CLOSE}`;
|
|
573
|
+
i = j + 1;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
skeleton += char;
|
|
577
|
+
i += 1;
|
|
578
|
+
}
|
|
579
|
+
return { skeleton, quoted };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function restoreQuoted(word: string, quoted: string[]): string {
|
|
583
|
+
return word.replace(
|
|
584
|
+
new RegExp(`${QUOTE_OPEN}(\\d+)${QUOTE_CLOSE}`, 'g'),
|
|
585
|
+
(_match, index: string) => quoted[Number(index)] ?? ''
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Command separators; `&` only when it is not part of a redirection (2>&1, &>). */
|
|
590
|
+
const SEGMENT_SEPARATOR = /\|\||&&|;|\||\n|[()]|(?<![<>])&(?!>)/;
|
|
591
|
+
|
|
592
|
+
/** A token that is a redirection (2>&1, >file, >>, <); a bare operator takes the next token. */
|
|
593
|
+
const REDIRECTION = /^\d*(?:[<>]|&>)/;
|
|
594
|
+
const BARE_REDIRECTION = /^\d*(?:[<>]{1,2}|&>)&?$/;
|
|
595
|
+
|
|
596
|
+
/** Options of `git push` that take a separate value. */
|
|
597
|
+
const PUSH_OPTIONS_WITH_VALUE = new Set([
|
|
598
|
+
'-o',
|
|
599
|
+
'--push-option',
|
|
600
|
+
'--repo',
|
|
601
|
+
'--receive-pack',
|
|
602
|
+
'--exec',
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The revisions a `git push <args>` sends, or null when it sends no work:
|
|
607
|
+
* only ref deletions (`--delete`, `-d`, `:ref` refspecs) or a dry run.
|
|
608
|
+
*/
|
|
609
|
+
function pushRevisions(args: string[]): string[] | null {
|
|
610
|
+
const positional: string[] = [];
|
|
611
|
+
const extra: string[] = [];
|
|
612
|
+
for (let k = 0; k < args.length; k++) {
|
|
613
|
+
const arg = args[k] ?? '';
|
|
614
|
+
if (REDIRECTION.test(arg)) {
|
|
615
|
+
if (BARE_REDIRECTION.test(arg)) k += 1;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (arg === '--delete' || arg === '--dry-run') return null;
|
|
619
|
+
if (/^-[A-Za-z]+$/.test(arg) && /[dn]/.test(arg.slice(1))) return null;
|
|
620
|
+
if (arg === '--all' || arg === '--branches' || arg === '--mirror') extra.push('--branches');
|
|
621
|
+
if (arg === '--tags') extra.push('--tags');
|
|
622
|
+
if (arg.startsWith('-')) {
|
|
623
|
+
if (PUSH_OPTIONS_WITH_VALUE.has(arg)) k += 1;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
positional.push(arg);
|
|
627
|
+
}
|
|
628
|
+
const refspecs = positional.slice(1);
|
|
629
|
+
if (refspecs.length > 0 && refspecs.every((refspec) => refspec.startsWith(':'))) return null;
|
|
630
|
+
const revisions: string[] = [];
|
|
631
|
+
for (let k = 0; k < refspecs.length; k++) {
|
|
632
|
+
const refspec = refspecs[k] ?? '';
|
|
633
|
+
if (refspec === 'tag' && refspecs[k + 1]) {
|
|
634
|
+
revisions.push(`refs/tags/${refspecs[k + 1]}`);
|
|
635
|
+
k += 1;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const source = refspec.replace(/^\+/, '').split(':')[0];
|
|
639
|
+
if (source) revisions.push(source);
|
|
640
|
+
}
|
|
641
|
+
if (revisions.length === 0 && extra.length === 0) revisions.push('HEAD');
|
|
642
|
+
return [...revisions, ...extra];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** `git [global options] push <args>`: the push args and any `-C` directory, else null. */
|
|
646
|
+
function gitPush(words: string[]): { args: string[]; directory: string | null } | null {
|
|
647
|
+
for (let i = 0; i < words.length; i++) {
|
|
648
|
+
if (words[i] !== 'git' && !/\/git$/.test(words[i] ?? '')) continue;
|
|
649
|
+
let j = i + 1;
|
|
650
|
+
let directory: string | null = null;
|
|
651
|
+
while (j < words.length && (words[j] ?? '').startsWith('-')) {
|
|
652
|
+
const option = words[j];
|
|
653
|
+
if (option === '-C' && j + 1 < words.length) {
|
|
654
|
+
directory = words[j + 1] ?? null;
|
|
655
|
+
j += 2;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (option === '-c' || /^--(?:git-dir|work-tree|namespace)$/.test(option ?? '')) {
|
|
659
|
+
j += 2;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
j += 1;
|
|
663
|
+
}
|
|
664
|
+
if (words[j] === 'push') return { args: words.slice(j + 1), directory };
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const OTHER_HANDOFFS = [
|
|
670
|
+
/\bgh\s+(?:pr\s+(?:create|merge)|release\s+create)\b/,
|
|
671
|
+
/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?deploy\b/,
|
|
672
|
+
/\b(?:vercel|wrangler|serverless|sls|sam|cdk|sst|fly|railway|netlify|dokploy)\s+deploy\b/,
|
|
673
|
+
/\bterraform\s+apply\b/,
|
|
674
|
+
/\bkubectl\s+apply\b/,
|
|
675
|
+
];
|
|
676
|
+
|
|
677
|
+
function joinDirectory(base: string | null, next: string): string {
|
|
678
|
+
if (isAbsolute(next) || base === null) return next;
|
|
679
|
+
return join(base, next);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The handoffs a shell command performs. Empty when it hands nothing off.
|
|
684
|
+
* Quoted text and here-document bodies are data, not commands; a `git push`
|
|
685
|
+
* that only deletes refs or is a dry run is not a handoff. `cd <dir>` before
|
|
686
|
+
* the handoff, and `git -C <dir>`, move it; a directory that cannot be known
|
|
687
|
+
* from the text (`cd ~`, `cd $X`, `cd -`) falls back to the event's cwd.
|
|
688
|
+
*/
|
|
689
|
+
export function parseHandoffCommand(toolName: string, command: string): HandoffTarget[] {
|
|
690
|
+
if (!SHELL_TOOLS.has(toolName.trim().toLowerCase())) return [];
|
|
691
|
+
if (!command.trim()) return [];
|
|
692
|
+
const { skeleton, quoted } = maskQuotes(stripHereDocuments(command));
|
|
693
|
+
const targets: HandoffTarget[] = [];
|
|
694
|
+
let directory: string | null = null;
|
|
695
|
+
for (const segment of skeleton.split(SEGMENT_SEPARATOR)) {
|
|
696
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
697
|
+
if (words.length === 0) continue;
|
|
698
|
+
if (words[0] === 'cd' || words[0] === 'pushd') {
|
|
699
|
+
const target = words[1] === undefined ? undefined : restoreQuoted(words[1], quoted);
|
|
700
|
+
directory =
|
|
701
|
+
target === undefined || target === '-' || target.startsWith('~') || target.includes('$')
|
|
702
|
+
? null
|
|
703
|
+
: joinDirectory(directory, target);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
const push = gitPush(words);
|
|
707
|
+
if (push) {
|
|
708
|
+
const revisions = pushRevisions(push.args.map((arg) => restoreQuoted(arg, quoted)));
|
|
709
|
+
if (revisions) {
|
|
710
|
+
targets.push({
|
|
711
|
+
directory: push.directory
|
|
712
|
+
? joinDirectory(directory, restoreQuoted(push.directory, quoted))
|
|
713
|
+
: directory,
|
|
714
|
+
revisions,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (OTHER_HANDOFFS.some((pattern) => pattern.test(segment.toLowerCase()))) {
|
|
720
|
+
targets.push({ directory });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return targets;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Most commits and files a push description carries. */
|
|
727
|
+
/**
|
|
728
|
+
* Bounds of a push description. Past any of them the description says only
|
|
729
|
+
* the repository, and the server falls back to every uncheckpointed edit in
|
|
730
|
+
* it: a description must never be partial, because a partial one could leave
|
|
731
|
+
* an edited file out and quiet the gate. The server's request schema accepts
|
|
732
|
+
* exactly these bounds, so a description can never fail the whole request.
|
|
733
|
+
*/
|
|
734
|
+
export const HANDOFF_LIMITS = {
|
|
735
|
+
commits: 200,
|
|
736
|
+
files: 500,
|
|
737
|
+
pathLength: 1024,
|
|
738
|
+
handoffs: 10,
|
|
739
|
+
} as const;
|
|
740
|
+
|
|
741
|
+
/** What one handoff carries, as the host sends it to the server's handoff gate. */
|
|
742
|
+
export interface HandoffDescription {
|
|
743
|
+
/** The repository (git common directory) it runs in. Absent when not in git or unknown. */
|
|
744
|
+
repo?: string;
|
|
745
|
+
/**
|
|
746
|
+
* For a git push: the repository-relative paths changed by the commits it
|
|
747
|
+
* sends (those on no remote-tracking ref). Absent when unknown, and for
|
|
748
|
+
* other handoffs (a deploy ships the working tree, not commits).
|
|
749
|
+
*/
|
|
750
|
+
files?: string[];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Marks the start of each commit in the `git log` output below. */
|
|
754
|
+
const COMMIT_MARKER = '\u001ecommit';
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Files changed by commits reachable from `revisions` and on no
|
|
758
|
+
* remote-tracking ref, or undefined when that cannot be said in full: git
|
|
759
|
+
* failed (a revision it does not know, a timeout), or the push is past a
|
|
760
|
+
* bound in HANDOFF_LIMITS.
|
|
761
|
+
*/
|
|
762
|
+
function pushedFiles(dir: string, revisions: string[]): string[] | undefined {
|
|
763
|
+
const out = runGit(
|
|
764
|
+
dir,
|
|
765
|
+
[
|
|
766
|
+
'log',
|
|
767
|
+
'--no-renames',
|
|
768
|
+
'--name-only',
|
|
769
|
+
// git refuses a raw control character in a format; %x1e is its escape.
|
|
770
|
+
'--format=%x1ecommit',
|
|
771
|
+
'-n',
|
|
772
|
+
String(HANDOFF_LIMITS.commits + 1),
|
|
773
|
+
...revisions,
|
|
774
|
+
'--not',
|
|
775
|
+
'--remotes',
|
|
776
|
+
'--',
|
|
777
|
+
],
|
|
778
|
+
GIT_STATUS_TIMEOUT_MS
|
|
779
|
+
);
|
|
780
|
+
if (out === null) return undefined;
|
|
781
|
+
const lines = out.split('\n').filter(Boolean);
|
|
782
|
+
const commits = lines.filter((line) => line === COMMIT_MARKER).length;
|
|
783
|
+
const files = [...new Set(lines.filter((line) => line !== COMMIT_MARKER))];
|
|
784
|
+
if (
|
|
785
|
+
commits > HANDOFF_LIMITS.commits ||
|
|
786
|
+
files.length > HANDOFF_LIMITS.files ||
|
|
787
|
+
files.some((path) => path.length > HANDOFF_LIMITS.pathLength)
|
|
788
|
+
) {
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
return files;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Per handoff in a shell command, what it carries: its repository and, for a
|
|
796
|
+
* git push, the files its new commits change. The server gates the handoff
|
|
797
|
+
* only when one of those files was edited by the session (any of its agents)
|
|
798
|
+
* after its last checkpoint. Empty when the command hands nothing off. A
|
|
799
|
+
* description that cannot be complete omits `files`, and the server then
|
|
800
|
+
* gates on every uncheckpointed edit in the repository.
|
|
801
|
+
*/
|
|
802
|
+
export function describeHandoffs(
|
|
803
|
+
toolName: string,
|
|
804
|
+
command: string,
|
|
805
|
+
cwd: string
|
|
806
|
+
): HandoffDescription[] {
|
|
807
|
+
const targets = parseHandoffCommand(toolName, command);
|
|
808
|
+
// More handoffs than the server accepts: describe none, and the server
|
|
809
|
+
// judges each by every uncheckpointed edit.
|
|
810
|
+
if (targets.length > HANDOFF_LIMITS.handoffs) return [];
|
|
811
|
+
return targets.map((target) => {
|
|
812
|
+
const dir = target.directory ? resolve(cwd, target.directory) : cwd;
|
|
813
|
+
const location = gitLocation(dir);
|
|
814
|
+
if (!location || location === 'none') return {};
|
|
815
|
+
const files = target.revisions ? pushedFiles(dir, target.revisions) : undefined;
|
|
816
|
+
return { repo: location.commonDir, ...(files ? { files } : {}) };
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Forget every recorded repository whose directory no longer exists. */
|
|
821
|
+
function pruneRemovedRoots(snapshotFile: string): void {
|
|
822
|
+
const snapshot = readSnapshot(snapshotFile);
|
|
823
|
+
if (!snapshot) return;
|
|
824
|
+
const roots = Object.fromEntries(
|
|
825
|
+
Object.entries(snapshot.roots).filter(([root]) => {
|
|
826
|
+
try {
|
|
827
|
+
return statSync(root).isDirectory();
|
|
828
|
+
} catch {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
);
|
|
833
|
+
try {
|
|
834
|
+
writeFileWhole(snapshotFile, JSON.stringify({ v: 2, roots } satisfies DirtySnapshot));
|
|
835
|
+
} catch {
|
|
836
|
+
// Best-effort: a stale root only costs a stat on the next event.
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The recorded repository root holding `path` no longer exists: its worktree
|
|
842
|
+
* was removed. A report for such a path is not an edit by this session.
|
|
843
|
+
*/
|
|
844
|
+
function recordedRootRemoved(snapshot: DirtySnapshot | null, path: string): boolean {
|
|
845
|
+
if (!snapshot) return false;
|
|
846
|
+
const root = rootFor(snapshot, path);
|
|
847
|
+
if (!root) return false;
|
|
848
|
+
try {
|
|
849
|
+
return !statSync(root).isDirectory();
|
|
850
|
+
} catch {
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
289
855
|
/** Largest file whose content is hashed for an edit fingerprint, in bytes. */
|
|
290
856
|
const EDIT_FINGERPRINT_MAX_BYTES = 8 * 1024 * 1024;
|
|
291
857
|
|
|
@@ -538,6 +1104,15 @@ export async function reportEditsWithinBudget(options: {
|
|
|
538
1104
|
for (const path of options.paths) {
|
|
539
1105
|
retryState.pending[path] ??= { attempts: 0, firstAt: start, nextAt: start };
|
|
540
1106
|
}
|
|
1107
|
+
const snapshot = readSnapshot(options.snapshotFile);
|
|
1108
|
+
let rootsRemoved = false;
|
|
1109
|
+
for (const path of Object.keys(retryState.pending)) {
|
|
1110
|
+
if (!recordedRootRemoved(snapshot, path)) continue;
|
|
1111
|
+
delete retryState.pending[path];
|
|
1112
|
+
rootsRemoved = true;
|
|
1113
|
+
log(`mnemonik: edit report for ${path} dropped (its repository was removed)`);
|
|
1114
|
+
}
|
|
1115
|
+
if (rootsRemoved) pruneRemovedRoots(options.snapshotFile);
|
|
541
1116
|
const due = [...new Set([...options.paths, ...Object.keys(retryState.pending)])].filter(
|
|
542
1117
|
(path) => retryState.pending[path] && retryState.pending[path].nextAt <= start
|
|
543
1118
|
);
|