@mnemonik/shared 7.99.12 → 7.99.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gitEditDetector.d.ts +20 -6
- package/dist/gitEditDetector.d.ts.map +1 -1
- package/dist/gitEditDetector.js +95 -22
- package/dist/gitEditDetector.js.map +1 -1
- package/package.json +1 -1
- package/src/gitEditDetector.ts +111 -26
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
* work" that is the right signal.
|
|
36
36
|
* - Only the repo containing cwd is observed. Not a git repo -> report nothing
|
|
37
37
|
* rather than guess.
|
|
38
|
+
*
|
|
39
|
+
* Two additions (2026-09-19), both found by a session that made eleven
|
|
40
|
+
* commits in a worktree and was never asked to checkpoint:
|
|
41
|
+
* - The snapshot holds one dirty set PER repository root. A session that
|
|
42
|
+
* moves between the main checkout and a worktree used to reset the snapshot
|
|
43
|
+
* on every move, absorbing whatever was dirty at that moment as pre-existing
|
|
44
|
+
* and never crediting it.
|
|
45
|
+
* - A shell call whose command contains `git commit` is credited with the
|
|
46
|
+
* files that commit changed. An edit-and-commit in one command leaves the
|
|
47
|
+
* tree clean by the time it is observed, so the dirty diff sees nothing.
|
|
48
|
+
* HEAD is recorded per root at every observation; only commits made since
|
|
49
|
+
* the last observation count, and never a merge.
|
|
38
50
|
*/
|
|
39
51
|
/**
|
|
40
52
|
* The current dirty set as absolute paths, or null when cwd is not inside a
|
|
@@ -48,16 +60,18 @@ export declare function listGitDirtyPaths(cwd: string): {
|
|
|
48
60
|
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
49
61
|
export declare function captureGitDirtyBaseline(snapshotFile: string, cwd: string): void;
|
|
50
62
|
/**
|
|
51
|
-
* After a shell call: paths newly dirty since the last observation
|
|
52
|
-
*
|
|
53
|
-
*
|
|
63
|
+
* After a shell call: paths newly dirty since the last observation of this
|
|
64
|
+
* repo, plus, when `command` contains `git commit`, the paths that commit
|
|
65
|
+
* changed. Capped. Leaves additions pending until the caller confirms them
|
|
66
|
+
* with addPathsToGitDirtySnapshot. A repo with no baseline yet gets one and
|
|
54
67
|
* reports nothing - degrading toward silence, never toward a false edit.
|
|
55
68
|
*/
|
|
56
|
-
export declare function diffGitDirtySnapshot(snapshotFile: string, cwd: string): string[];
|
|
69
|
+
export declare function diffGitDirtySnapshot(snapshotFile: string, cwd: string, command?: string): string[];
|
|
57
70
|
/**
|
|
58
71
|
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
59
|
-
* shell diff does not re-credit them. Only when a baseline exists
|
|
60
|
-
* partial snapshot would make later diffs
|
|
72
|
+
* shell diff does not re-credit them. Only when a baseline exists for the
|
|
73
|
+
* repo that holds the path - seeding a partial snapshot would make later diffs
|
|
74
|
+
* report pre-existing dirt.
|
|
61
75
|
*/
|
|
62
76
|
export declare function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]): void;
|
|
63
77
|
//# sourceMappingURL=gitEditDetector.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gitEditDetector.d.ts","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"gitEditDetector.d.ts","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAiDH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAuBvF;AA+ED,oFAAoF;AACpF,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAK/E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,SAAK,GAAG,MAAM,EAAE,CAqB9F;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAWtF"}
|
package/dist/gitEditDetector.js
CHANGED
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
* work" that is the right signal.
|
|
36
36
|
* - Only the repo containing cwd is observed. Not a git repo -> report nothing
|
|
37
37
|
* rather than guess.
|
|
38
|
+
*
|
|
39
|
+
* Two additions (2026-09-19), both found by a session that made eleven
|
|
40
|
+
* commits in a worktree and was never asked to checkpoint:
|
|
41
|
+
* - The snapshot holds one dirty set PER repository root. A session that
|
|
42
|
+
* moves between the main checkout and a worktree used to reset the snapshot
|
|
43
|
+
* on every move, absorbing whatever was dirty at that moment as pre-existing
|
|
44
|
+
* and never crediting it.
|
|
45
|
+
* - A shell call whose command contains `git commit` is credited with the
|
|
46
|
+
* files that commit changed. An edit-and-commit in one command leaves the
|
|
47
|
+
* tree clean by the time it is observed, so the dirty diff sees nothing.
|
|
48
|
+
* HEAD is recorded per root at every observation; only commits made since
|
|
49
|
+
* the last observation count, and never a merge.
|
|
38
50
|
*/
|
|
39
51
|
import { execFileSync } from 'node:child_process';
|
|
40
52
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -94,30 +106,77 @@ export function listGitDirtyPaths(cwd) {
|
|
|
94
106
|
}
|
|
95
107
|
return { root, paths };
|
|
96
108
|
}
|
|
109
|
+
/** HEAD of the repo at cwd, or undefined before the first commit. */
|
|
110
|
+
function currentHead(cwd) {
|
|
111
|
+
return (runGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD'], GIT_REV_PARSE_TIMEOUT_MS)?.trim() ||
|
|
112
|
+
undefined);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Files changed by the commits made between two heads, or by HEAD alone when
|
|
116
|
+
* there is no earlier head. Empty when any of those commits is a merge: a
|
|
117
|
+
* merge brings in other people's files and none of them is this session's
|
|
118
|
+
* edit. `git commit --amend` orphans the old head, so the range is still the
|
|
119
|
+
* one new commit and the diff is the amended change.
|
|
120
|
+
*/
|
|
121
|
+
function committedPaths(cwd, root, from, to) {
|
|
122
|
+
const range = from && from !== to ? `${from}..${to}` : to;
|
|
123
|
+
const parents = runGit(cwd, from ? ['log', '--format=%P', range] : ['log', '--format=%P', '-1', to], GIT_STATUS_TIMEOUT_MS);
|
|
124
|
+
if (parents === null)
|
|
125
|
+
return [];
|
|
126
|
+
const lines = parents.split('\n').filter(Boolean);
|
|
127
|
+
if (lines.length === 0 || lines.some((line) => line.includes(' ')))
|
|
128
|
+
return [];
|
|
129
|
+
const names = from
|
|
130
|
+
? runGit(cwd, ['diff', '--name-only', '-z', from, to], GIT_STATUS_TIMEOUT_MS)
|
|
131
|
+
: runGit(cwd, ['show', '--name-only', '-z', '--format=', to], GIT_STATUS_TIMEOUT_MS);
|
|
132
|
+
if (names === null)
|
|
133
|
+
return [];
|
|
134
|
+
return names
|
|
135
|
+
.split('\0')
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.map((name) => join(root, name));
|
|
138
|
+
}
|
|
97
139
|
function readSnapshot(snapshotFile) {
|
|
98
140
|
try {
|
|
99
141
|
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8'));
|
|
100
|
-
if (parsed?.v
|
|
101
|
-
return
|
|
142
|
+
if (parsed?.v === 1 && typeof parsed.root === 'string' && Array.isArray(parsed.paths)) {
|
|
143
|
+
return { v: 2, roots: { [parsed.root]: { paths: parsed.paths } } };
|
|
102
144
|
}
|
|
145
|
+
if (parsed?.v !== 2 || typeof parsed.roots !== 'object' || parsed.roots === null)
|
|
146
|
+
return null;
|
|
103
147
|
return parsed;
|
|
104
148
|
}
|
|
105
149
|
catch {
|
|
106
150
|
return null;
|
|
107
151
|
}
|
|
108
152
|
}
|
|
109
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Persist one root's list into the snapshot, keeping every other root. The
|
|
155
|
+
* whole file stays under MAX_SNAPSHOT_PATHS: when the total would exceed it,
|
|
156
|
+
* only the root being written survives.
|
|
157
|
+
*/
|
|
158
|
+
function writeRoot(snapshotFile, root, entry) {
|
|
110
159
|
try {
|
|
160
|
+
const roots = { ...(readSnapshot(snapshotFile)?.roots ?? {}), [root]: entry };
|
|
161
|
+
const total = Object.values(roots).reduce((sum, r) => sum + r.paths.length, 0);
|
|
162
|
+
const snapshot = {
|
|
163
|
+
v: 2,
|
|
164
|
+
roots: total > MAX_SNAPSHOT_PATHS ? { [root]: entry } : roots,
|
|
165
|
+
};
|
|
111
166
|
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
112
|
-
writeFileSync(snapshotFile, JSON.stringify({
|
|
113
|
-
mode: 0o600,
|
|
114
|
-
});
|
|
167
|
+
writeFileSync(snapshotFile, JSON.stringify(snapshot), { mode: 0o600 });
|
|
115
168
|
}
|
|
116
169
|
catch {
|
|
117
170
|
// Best-effort: a missing snapshot degrades to "report nothing", never to a
|
|
118
171
|
// wrong report.
|
|
119
172
|
}
|
|
120
173
|
}
|
|
174
|
+
/** The recorded root whose directory contains this absolute path, if any. */
|
|
175
|
+
function rootFor(snapshot, path) {
|
|
176
|
+
return Object.keys(snapshot.roots)
|
|
177
|
+
.filter((root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`))
|
|
178
|
+
.sort((x, y) => y.length - x.length)[0];
|
|
179
|
+
}
|
|
121
180
|
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
122
181
|
export function captureGitDirtyBaseline(snapshotFile, cwd) {
|
|
123
182
|
if (!snapshotFile)
|
|
@@ -125,34 +184,45 @@ export function captureGitDirtyBaseline(snapshotFile, cwd) {
|
|
|
125
184
|
const current = listGitDirtyPaths(cwd);
|
|
126
185
|
if (!current)
|
|
127
186
|
return;
|
|
128
|
-
|
|
187
|
+
writeRoot(snapshotFile, current.root, { paths: current.paths, head: currentHead(cwd) });
|
|
129
188
|
}
|
|
130
189
|
/**
|
|
131
|
-
* After a shell call: paths newly dirty since the last observation
|
|
132
|
-
*
|
|
133
|
-
*
|
|
190
|
+
* After a shell call: paths newly dirty since the last observation of this
|
|
191
|
+
* repo, plus, when `command` contains `git commit`, the paths that commit
|
|
192
|
+
* changed. Capped. Leaves additions pending until the caller confirms them
|
|
193
|
+
* with addPathsToGitDirtySnapshot. A repo with no baseline yet gets one and
|
|
134
194
|
* reports nothing - degrading toward silence, never toward a false edit.
|
|
135
195
|
*/
|
|
136
|
-
export function diffGitDirtySnapshot(snapshotFile, cwd) {
|
|
196
|
+
export function diffGitDirtySnapshot(snapshotFile, cwd, command = '') {
|
|
137
197
|
if (!snapshotFile)
|
|
138
198
|
return [];
|
|
139
199
|
const current = listGitDirtyPaths(cwd);
|
|
140
200
|
if (!current)
|
|
141
201
|
return [];
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
202
|
+
const head = currentHead(cwd);
|
|
203
|
+
const baseline = readSnapshot(snapshotFile)?.roots[current.root];
|
|
204
|
+
if (!baseline) {
|
|
205
|
+
writeRoot(snapshotFile, current.root, { paths: current.paths, head });
|
|
145
206
|
return [];
|
|
146
207
|
}
|
|
147
208
|
const known = new Set(baseline.paths);
|
|
148
209
|
const additions = current.paths.filter((path) => !known.has(path));
|
|
149
|
-
|
|
210
|
+
if (head && head !== baseline.head && /\bgit\s+commit\b/.test(command)) {
|
|
211
|
+
for (const path of committedPaths(cwd, current.root, baseline.head, head))
|
|
212
|
+
if (!known.has(path) && !additions.includes(path))
|
|
213
|
+
additions.push(path);
|
|
214
|
+
}
|
|
215
|
+
writeRoot(snapshotFile, current.root, {
|
|
216
|
+
paths: current.paths.filter((path) => known.has(path)),
|
|
217
|
+
head,
|
|
218
|
+
});
|
|
150
219
|
return additions.slice(0, MAX_REPORTED_PATHS_PER_CALL);
|
|
151
220
|
}
|
|
152
221
|
/**
|
|
153
222
|
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
154
|
-
* shell diff does not re-credit them. Only when a baseline exists
|
|
155
|
-
* partial snapshot would make later diffs
|
|
223
|
+
* shell diff does not re-credit them. Only when a baseline exists for the
|
|
224
|
+
* repo that holds the path - seeding a partial snapshot would make later diffs
|
|
225
|
+
* report pre-existing dirt.
|
|
156
226
|
*/
|
|
157
227
|
export function addPathsToGitDirtySnapshot(snapshotFile, paths) {
|
|
158
228
|
if (!snapshotFile || paths.length === 0)
|
|
@@ -160,10 +230,13 @@ export function addPathsToGitDirtySnapshot(snapshotFile, paths) {
|
|
|
160
230
|
const snapshot = readSnapshot(snapshotFile);
|
|
161
231
|
if (!snapshot)
|
|
162
232
|
return;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
233
|
+
for (const path of paths) {
|
|
234
|
+
const root = rootFor(snapshot, path);
|
|
235
|
+
const entry = root ? snapshot.roots[root] : undefined;
|
|
236
|
+
if (!root || !entry || entry.paths.includes(path))
|
|
237
|
+
continue;
|
|
238
|
+
entry.paths = [...entry.paths, path];
|
|
239
|
+
writeRoot(snapshotFile, root, entry);
|
|
240
|
+
}
|
|
168
241
|
}
|
|
169
242
|
//# sourceMappingURL=gitEditDetector.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gitEditDetector.js","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"gitEditDetector.js","sourceRoot":"","sources":["../src/gitEditDetector.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/gitEditDetector.ts
CHANGED
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
* work" that is the right signal.
|
|
36
36
|
* - Only the repo containing cwd is observed. Not a git repo -> report nothing
|
|
37
37
|
* rather than guess.
|
|
38
|
+
*
|
|
39
|
+
* Two additions (2026-09-19), both found by a session that made eleven
|
|
40
|
+
* commits in a worktree and was never asked to checkpoint:
|
|
41
|
+
* - The snapshot holds one dirty set PER repository root. A session that
|
|
42
|
+
* moves between the main checkout and a worktree used to reset the snapshot
|
|
43
|
+
* on every move, absorbing whatever was dirty at that moment as pre-existing
|
|
44
|
+
* and never crediting it.
|
|
45
|
+
* - A shell call whose command contains `git commit` is credited with the
|
|
46
|
+
* files that commit changed. An edit-and-commit in one command leaves the
|
|
47
|
+
* tree clean by the time it is observed, so the dirty diff sees nothing.
|
|
48
|
+
* HEAD is recorded per root at every observation; only commits made since
|
|
49
|
+
* the last observation count, and never a merge.
|
|
38
50
|
*/
|
|
39
51
|
|
|
40
52
|
import { execFileSync } from 'node:child_process';
|
|
@@ -52,7 +64,18 @@ const MAX_REPORTED_PATHS_PER_CALL = 25;
|
|
|
52
64
|
*/
|
|
53
65
|
const MAX_SNAPSHOT_PATHS = 2000;
|
|
54
66
|
|
|
67
|
+
interface RootSnapshot {
|
|
68
|
+
paths: string[];
|
|
69
|
+
head?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
interface DirtySnapshot {
|
|
73
|
+
v: 2;
|
|
74
|
+
roots: Record<string, RootSnapshot>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The pre-2026-09-19 shape: one root, one list. Read once, written as v2. */
|
|
78
|
+
interface LegacyDirtySnapshot {
|
|
56
79
|
v: 1;
|
|
57
80
|
root: string;
|
|
58
81
|
paths: string[];
|
|
@@ -103,74 +126,136 @@ export function listGitDirtyPaths(cwd: string): { root: string; paths: string[]
|
|
|
103
126
|
return { root, paths };
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
/** HEAD of the repo at cwd, or undefined before the first commit. */
|
|
130
|
+
function currentHead(cwd: string): string | undefined {
|
|
131
|
+
return (
|
|
132
|
+
runGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD'], GIT_REV_PARSE_TIMEOUT_MS)?.trim() ||
|
|
133
|
+
undefined
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Files changed by the commits made between two heads, or by HEAD alone when
|
|
139
|
+
* there is no earlier head. Empty when any of those commits is a merge: a
|
|
140
|
+
* merge brings in other people's files and none of them is this session's
|
|
141
|
+
* edit. `git commit --amend` orphans the old head, so the range is still the
|
|
142
|
+
* one new commit and the diff is the amended change.
|
|
143
|
+
*/
|
|
144
|
+
function committedPaths(cwd: string, root: string, from: string | undefined, to: string): string[] {
|
|
145
|
+
const range = from && from !== to ? `${from}..${to}` : to;
|
|
146
|
+
const parents = runGit(
|
|
147
|
+
cwd,
|
|
148
|
+
from ? ['log', '--format=%P', range] : ['log', '--format=%P', '-1', to],
|
|
149
|
+
GIT_STATUS_TIMEOUT_MS
|
|
150
|
+
);
|
|
151
|
+
if (parents === null) return [];
|
|
152
|
+
const lines = parents.split('\n').filter(Boolean);
|
|
153
|
+
if (lines.length === 0 || lines.some((line) => line.includes(' '))) return [];
|
|
154
|
+
const names = from
|
|
155
|
+
? runGit(cwd, ['diff', '--name-only', '-z', from, to], GIT_STATUS_TIMEOUT_MS)
|
|
156
|
+
: runGit(cwd, ['show', '--name-only', '-z', '--format=', to], GIT_STATUS_TIMEOUT_MS);
|
|
157
|
+
if (names === null) return [];
|
|
158
|
+
return names
|
|
159
|
+
.split('\0')
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.map((name) => join(root, name));
|
|
162
|
+
}
|
|
163
|
+
|
|
106
164
|
function readSnapshot(snapshotFile: string): DirtySnapshot | null {
|
|
107
165
|
try {
|
|
108
|
-
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8')) as
|
|
109
|
-
|
|
110
|
-
|
|
166
|
+
const parsed = JSON.parse(readFileSync(snapshotFile, 'utf8')) as
|
|
167
|
+
DirtySnapshot | LegacyDirtySnapshot;
|
|
168
|
+
if (parsed?.v === 1 && typeof parsed.root === 'string' && Array.isArray(parsed.paths)) {
|
|
169
|
+
return { v: 2, roots: { [parsed.root]: { paths: parsed.paths } } };
|
|
111
170
|
}
|
|
171
|
+
if (parsed?.v !== 2 || typeof parsed.roots !== 'object' || parsed.roots === null) return null;
|
|
112
172
|
return parsed;
|
|
113
173
|
} catch {
|
|
114
174
|
return null;
|
|
115
175
|
}
|
|
116
176
|
}
|
|
117
177
|
|
|
118
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Persist one root's list into the snapshot, keeping every other root. The
|
|
180
|
+
* whole file stays under MAX_SNAPSHOT_PATHS: when the total would exceed it,
|
|
181
|
+
* only the root being written survives.
|
|
182
|
+
*/
|
|
183
|
+
function writeRoot(snapshotFile: string, root: string, entry: RootSnapshot): void {
|
|
119
184
|
try {
|
|
185
|
+
const roots = { ...(readSnapshot(snapshotFile)?.roots ?? {}), [root]: entry };
|
|
186
|
+
const total = Object.values(roots).reduce((sum, r) => sum + r.paths.length, 0);
|
|
187
|
+
const snapshot: DirtySnapshot = {
|
|
188
|
+
v: 2,
|
|
189
|
+
roots: total > MAX_SNAPSHOT_PATHS ? { [root]: entry } : roots,
|
|
190
|
+
};
|
|
120
191
|
mkdirSync(dirname(snapshotFile), { recursive: true, mode: 0o700 });
|
|
121
|
-
writeFileSync(snapshotFile, JSON.stringify({
|
|
122
|
-
mode: 0o600,
|
|
123
|
-
});
|
|
192
|
+
writeFileSync(snapshotFile, JSON.stringify(snapshot), { mode: 0o600 });
|
|
124
193
|
} catch {
|
|
125
194
|
// Best-effort: a missing snapshot degrades to "report nothing", never to a
|
|
126
195
|
// wrong report.
|
|
127
196
|
}
|
|
128
197
|
}
|
|
129
198
|
|
|
199
|
+
/** The recorded root whose directory contains this absolute path, if any. */
|
|
200
|
+
function rootFor(snapshot: DirtySnapshot, path: string): string | undefined {
|
|
201
|
+
return Object.keys(snapshot.roots)
|
|
202
|
+
.filter((root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`))
|
|
203
|
+
.sort((x, y) => y.length - x.length)[0];
|
|
204
|
+
}
|
|
205
|
+
|
|
130
206
|
/** Session start: baseline the dirty set so pre-existing dirt is never reported. */
|
|
131
207
|
export function captureGitDirtyBaseline(snapshotFile: string, cwd: string): void {
|
|
132
208
|
if (!snapshotFile) return;
|
|
133
209
|
const current = listGitDirtyPaths(cwd);
|
|
134
210
|
if (!current) return;
|
|
135
|
-
|
|
211
|
+
writeRoot(snapshotFile, current.root, { paths: current.paths, head: currentHead(cwd) });
|
|
136
212
|
}
|
|
137
213
|
|
|
138
214
|
/**
|
|
139
|
-
* After a shell call: paths newly dirty since the last observation
|
|
140
|
-
*
|
|
141
|
-
*
|
|
215
|
+
* After a shell call: paths newly dirty since the last observation of this
|
|
216
|
+
* repo, plus, when `command` contains `git commit`, the paths that commit
|
|
217
|
+
* changed. Capped. Leaves additions pending until the caller confirms them
|
|
218
|
+
* with addPathsToGitDirtySnapshot. A repo with no baseline yet gets one and
|
|
142
219
|
* reports nothing - degrading toward silence, never toward a false edit.
|
|
143
220
|
*/
|
|
144
|
-
export function diffGitDirtySnapshot(snapshotFile: string, cwd: string): string[] {
|
|
221
|
+
export function diffGitDirtySnapshot(snapshotFile: string, cwd: string, command = ''): string[] {
|
|
145
222
|
if (!snapshotFile) return [];
|
|
146
223
|
const current = listGitDirtyPaths(cwd);
|
|
147
224
|
if (!current) return [];
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
225
|
+
const head = currentHead(cwd);
|
|
226
|
+
const baseline = readSnapshot(snapshotFile)?.roots[current.root];
|
|
227
|
+
if (!baseline) {
|
|
228
|
+
writeRoot(snapshotFile, current.root, { paths: current.paths, head });
|
|
151
229
|
return [];
|
|
152
230
|
}
|
|
153
231
|
const known = new Set(baseline.paths);
|
|
154
232
|
const additions = current.paths.filter((path) => !known.has(path));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
233
|
+
if (head && head !== baseline.head && /\bgit\s+commit\b/.test(command)) {
|
|
234
|
+
for (const path of committedPaths(cwd, current.root, baseline.head, head))
|
|
235
|
+
if (!known.has(path) && !additions.includes(path)) additions.push(path);
|
|
236
|
+
}
|
|
237
|
+
writeRoot(snapshotFile, current.root, {
|
|
238
|
+
paths: current.paths.filter((path) => known.has(path)),
|
|
239
|
+
head,
|
|
240
|
+
});
|
|
160
241
|
return additions.slice(0, MAX_REPORTED_PATHS_PER_CALL);
|
|
161
242
|
}
|
|
162
243
|
|
|
163
244
|
/**
|
|
164
245
|
* After an edit tool: fold tool-reported edits into the snapshot so the next
|
|
165
|
-
* shell diff does not re-credit them. Only when a baseline exists
|
|
166
|
-
* partial snapshot would make later diffs
|
|
246
|
+
* shell diff does not re-credit them. Only when a baseline exists for the
|
|
247
|
+
* repo that holds the path - seeding a partial snapshot would make later diffs
|
|
248
|
+
* report pre-existing dirt.
|
|
167
249
|
*/
|
|
168
250
|
export function addPathsToGitDirtySnapshot(snapshotFile: string, paths: string[]): void {
|
|
169
251
|
if (!snapshotFile || paths.length === 0) return;
|
|
170
252
|
const snapshot = readSnapshot(snapshotFile);
|
|
171
253
|
if (!snapshot) return;
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
254
|
+
for (const path of paths) {
|
|
255
|
+
const root = rootFor(snapshot, path);
|
|
256
|
+
const entry = root ? snapshot.roots[root] : undefined;
|
|
257
|
+
if (!root || !entry || entry.paths.includes(path)) continue;
|
|
258
|
+
entry.paths = [...entry.paths, path];
|
|
259
|
+
writeRoot(snapshotFile, root, entry);
|
|
260
|
+
}
|
|
176
261
|
}
|