nano-git 0.2.3 → 0.3.1

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/README.md CHANGED
@@ -347,6 +347,12 @@ console.log(obj.type); // => "blob"
347
347
  bun run examples/demo.ts
348
348
  ```
349
349
 
350
+ 运行 Virtual Workdir diff 基准:
351
+
352
+ ```bash
353
+ bun run bench:workdir-diff
354
+ ```
355
+
350
356
  演示脚本展示了:
351
357
 
352
358
  - SHA-1 哈希计算
@@ -0,0 +1,76 @@
1
+ import { resolvePath } from "./session-internal.mjs";
2
+ //#region src/workdir/change-index-plan.ts
3
+ /**
4
+ * Virtual Workdir change-index 刷新策略
5
+ *
6
+ * 把 session 写路径里的“是否允许增量刷新”判断与
7
+ * “应该执行哪种 change-index 更新动作”集中到单独模块。
8
+ */
9
+ /**
10
+ * 创建 change-index 刷新策略器。
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const planner = createChangeIndexPlanner(source, state, {
15
+ * rebuildAll() {},
16
+ * refreshPath() {},
17
+ * rewriteRename() {},
18
+ * writeCopy() {},
19
+ * });
20
+ * expect(planner.planRefreshForPath("a.txt").kind).toBe("refresh-path");
21
+ * ```
22
+ */
23
+ function createChangeIndexPlanner(source, state, actions) {
24
+ const canIncrementallyRefreshPath = (path, options) => {
25
+ const record = state.getChangeRecord(path);
26
+ if (record !== null && record.source !== null) return false;
27
+ const resolved = resolvePath(source, state, path);
28
+ if (!resolved.found || resolved.node === null) return options?.treatMissingAsIncremental === true;
29
+ return resolved.node.state.kind !== "directory";
30
+ };
31
+ const canIncrementallyWriteCopy = (from) => {
32
+ const resolved = resolvePath(source, state, from);
33
+ if (!resolved.found || resolved.node === null) return false;
34
+ return resolved.node.state.kind !== "directory";
35
+ };
36
+ return {
37
+ apply(plan) {
38
+ switch (plan.kind) {
39
+ case "rebuild-all":
40
+ actions.rebuildAll();
41
+ return;
42
+ case "refresh-path":
43
+ actions.refreshPath(plan.path);
44
+ return;
45
+ case "rewrite-rename":
46
+ actions.rewriteRename(plan.from, plan.to);
47
+ return;
48
+ case "write-copy":
49
+ actions.writeCopy(plan.from, plan.to);
50
+ return;
51
+ }
52
+ },
53
+ planRefreshForPath(path, options) {
54
+ return canIncrementallyRefreshPath(path, options) ? {
55
+ kind: "refresh-path",
56
+ path
57
+ } : { kind: "rebuild-all" };
58
+ },
59
+ planRewriteForRename(from, to) {
60
+ return canIncrementallyRefreshPath(from) && canIncrementallyRefreshPath(to, { treatMissingAsIncremental: true }) ? {
61
+ kind: "rewrite-rename",
62
+ from,
63
+ to
64
+ } : { kind: "rebuild-all" };
65
+ },
66
+ planWriteForCopy(from, to) {
67
+ return canIncrementallyWriteCopy(from) && canIncrementallyRefreshPath(to, { treatMissingAsIncremental: true }) ? {
68
+ kind: "write-copy",
69
+ from,
70
+ to
71
+ } : { kind: "rebuild-all" };
72
+ }
73
+ };
74
+ }
75
+ //#endregion
76
+ export { createChangeIndexPlanner };
@@ -0,0 +1,21 @@
1
+ import { VirtualDiffObject, VirtualDiffSource } from "./core.mjs";
2
+
3
+ //#region src/workdir/change-index.d.ts
4
+ /**
5
+ * 规范化变更记录
6
+ *
7
+ * 仅保留相对 baseTree 的最终净效应;
8
+ * 若路径已恢复为 clean,则不保存记录。
9
+ */
10
+ interface NormalizedChangeRecord {
11
+ /** 当前路径 */
12
+ readonly path: string;
13
+ /** 变更前对象 */
14
+ readonly previous: VirtualDiffObject | null;
15
+ /** 变更后对象 */
16
+ readonly current: VirtualDiffObject | null;
17
+ /** rename/copy 来源 */
18
+ readonly source: VirtualDiffSource | null;
19
+ }
20
+ //#endregion
21
+ export { NormalizedChangeRecord };
@@ -0,0 +1,413 @@
1
+ import { hashObject } from "../core/hash-digest.mjs";
2
+ import { readRepoBlobContent, readRepoTree } from "./origin.mjs";
3
+ import { getDirectoryChildrenView, joinChildPath, resolveCurrentLeafAtPath } from "./session-internal.mjs";
4
+ import { observeListedDirectoryChild } from "./directory-view.mjs";
5
+ //#region src/workdir/change-index.ts
6
+ /**
7
+ * Virtual Workdir 规范化变更索引
8
+ *
9
+ * 第一阶段先完成模型切换:
10
+ * - 对外 `diff()` 改为读取规范化变更索引
11
+ * - 写事务结束前重建净效应表并持久化
12
+ *
13
+ * 当前实现仍复用全量快照算法重建索引,
14
+ * 后续阶段再替换为真正的增量维护。
15
+ */
16
+ const baseSnapshotCache = /* @__PURE__ */ new WeakMap();
17
+ /**
18
+ * 重建当前 session 的规范化变更索引。
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const records = rebuildNormalizedChangeIndex(repo.objects, state);
23
+ * expect(records.map((record) => record.path)).toEqual(["hello.txt"]);
24
+ * ```
25
+ */
26
+ function rebuildNormalizedChangeIndex(source, state, cache) {
27
+ const baseSnapshot = baseSnapshotViewForTree(source, state.readBaseTree());
28
+ const baseEntries = baseSnapshot.entries;
29
+ const currentEntries = snapshotCurrentTree(source, state, cache);
30
+ const baseByPath = baseSnapshot.byPath;
31
+ const currentByPath = new Map(currentEntries.map((entry) => [entry.path, entry]));
32
+ const deletes = /* @__PURE__ */ new Map();
33
+ const adds = /* @__PURE__ */ new Map();
34
+ const out = [];
35
+ const allPaths = /* @__PURE__ */ new Set([...baseByPath.keys(), ...currentByPath.keys()]);
36
+ for (const path of Array.from(allPaths).sort()) {
37
+ const previous = baseByPath.get(path) ?? null;
38
+ const current = currentByPath.get(path) ?? null;
39
+ if (previous !== null && current !== null) {
40
+ if (!isSameObject(previous.object, current.object)) out.push({
41
+ path,
42
+ previous: previous.object,
43
+ current: current.object,
44
+ source: null
45
+ });
46
+ continue;
47
+ }
48
+ if (previous !== null) {
49
+ deletes.set(path, {
50
+ path,
51
+ previous: previous.object,
52
+ current: null,
53
+ source: null
54
+ });
55
+ continue;
56
+ }
57
+ if (current !== null) adds.set(path, {
58
+ path,
59
+ previous: null,
60
+ current: current.object,
61
+ source: null
62
+ });
63
+ }
64
+ const unmatchedDeletesBySignature = indexDeletesBySignature(deletes);
65
+ const matchedDeletePaths = /* @__PURE__ */ new Set();
66
+ for (const [path, addRecord] of Array.from(adds.entries()).sort(([left], [right]) => left.localeCompare(right))) {
67
+ const current = currentByPath.get(path);
68
+ if (current === void 0 || current.originSignature === null) continue;
69
+ const renameFrom = (unmatchedDeletesBySignature.get(current.originSignature) ?? []).find((candidate) => !matchedDeletePaths.has(candidate.path));
70
+ if (renameFrom !== void 0) {
71
+ matchedDeletePaths.add(renameFrom.path);
72
+ adds.delete(path);
73
+ deletes.delete(renameFrom.path);
74
+ out.push({
75
+ path,
76
+ previous: renameFrom.previous,
77
+ current: addRecord.current,
78
+ source: {
79
+ kind: "rename",
80
+ path: renameFrom.path
81
+ }
82
+ });
83
+ continue;
84
+ }
85
+ const copyFrom = findCopySource(baseEntries, current);
86
+ if (copyFrom !== null) {
87
+ adds.delete(path);
88
+ out.push({
89
+ path,
90
+ previous: null,
91
+ current: addRecord.current,
92
+ source: {
93
+ kind: "copy",
94
+ path: copyFrom.path
95
+ }
96
+ });
97
+ }
98
+ }
99
+ out.push(...deletes.values(), ...adds.values());
100
+ return out.sort((left, right) => left.path.localeCompare(right.path));
101
+ }
102
+ /**
103
+ * 将规范化变更索引导出为公开 diff 结果。
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const diff = exportVirtualDiffFromChangeRecords(records);
108
+ * expect(diff).toHaveLength(1);
109
+ * ```
110
+ */
111
+ function exportVirtualDiffFromChangeRecords(records) {
112
+ return records.map((record) => {
113
+ if (record.previous === null && record.current !== null) return {
114
+ kind: "create",
115
+ path: record.path,
116
+ current: record.current,
117
+ source: record.source ?? void 0
118
+ };
119
+ if (record.previous !== null && record.current === null) return {
120
+ kind: "remove",
121
+ path: record.path,
122
+ previous: record.previous
123
+ };
124
+ if (record.previous !== null && record.current !== null) return {
125
+ kind: "update",
126
+ path: record.path,
127
+ previous: record.previous,
128
+ current: record.current,
129
+ changes: diffChanges(record.previous, record.current),
130
+ source: record.source ?? void 0
131
+ };
132
+ throw new Error(`Invalid normalized change record at path: ${record.path}`);
133
+ }).sort((left, right) => left.path.localeCompare(right.path));
134
+ }
135
+ /**
136
+ * 将新索引完整写回状态存储。
137
+ */
138
+ function replaceChangeRecords(state, records) {
139
+ const nextByPath = new Map(records.map((record) => [record.path, record]));
140
+ for (const existing of state.listChangeRecords()) if (!nextByPath.has(existing.path)) state.deleteChangeRecord(existing.path);
141
+ for (const record of records) state.setChangeRecord(record);
142
+ }
143
+ /**
144
+ * 仅刷新单一路径的规范化变更记录。
145
+ *
146
+ * 适用于同路径叶子节点写入等高频场景,
147
+ * 可避免在简单写操作后重建整张索引。
148
+ */
149
+ function refreshChangeRecordForPath(source, state, path, cache) {
150
+ const nextRecord = computeChangeRecordForPath(source, state, path, state.getChangeRecord(path), cache);
151
+ if (nextRecord === null) {
152
+ state.deleteChangeRecord(path);
153
+ return;
154
+ }
155
+ state.setChangeRecord(nextRecord);
156
+ }
157
+ /**
158
+ * 将单一路径的变更记录折叠为 rename 目标路径。
159
+ *
160
+ * 仅适用于叶子节点 rename;
161
+ * 目录及无法判定来源的情况应由调用方回退到全量重建。
162
+ */
163
+ function rewriteChangeRecordForRename(source, state, from, to, cache) {
164
+ const previousRecord = state.getChangeRecord(from);
165
+ const currentTarget = snapshotCurrentEntryAtPath(source, state, to, cache);
166
+ if (currentTarget === null) throw new Error(`Cannot rewrite rename change record for missing path: ${to}`);
167
+ const nextRecord = computeRenameRecordForPath(source, state, from, to, previousRecord, currentTarget);
168
+ if (nextRecord === null) throw new Error(`Cannot rewrite rename change record from '${from}' to '${to}'`);
169
+ state.deleteChangeRecord(from);
170
+ state.setChangeRecord(nextRecord);
171
+ }
172
+ /**
173
+ * 为 copy 目标路径写入折叠后的变更记录。
174
+ *
175
+ * 仅适用于叶子节点 copy;
176
+ * session-only 来源允许退化为普通 create。
177
+ */
178
+ function writeChangeRecordForCopy(source, state, from, to, cache) {
179
+ const sourceRecord = state.getChangeRecord(from);
180
+ const currentTarget = snapshotCurrentEntryAtPath(source, state, to, cache);
181
+ if (currentTarget === null) throw new Error(`Cannot write copy change record for missing path: ${to}`);
182
+ state.setChangeRecord({
183
+ path: to,
184
+ previous: null,
185
+ current: currentTarget.object,
186
+ source: deriveCopySource(from, sourceRecord, source, state)
187
+ });
188
+ }
189
+ function snapshotBaseTree(source, treeHash, dirPath) {
190
+ if (dirPath === "") {
191
+ const cached = getCachedBaseSnapshot(source, treeHash);
192
+ if (cached !== null) return [...cached.entries];
193
+ }
194
+ const tree = readRepoTree(source, treeHash, dirPath);
195
+ const out = [];
196
+ for (const entry of tree.entries) {
197
+ const path = joinChildPath(dirPath, entry.name);
198
+ if (entry.mode === "40000") {
199
+ out.push(...snapshotBaseTree(source, entry.hash, path));
200
+ continue;
201
+ }
202
+ const object = createDiffObject(normalizeBlobMode(entry.mode), entry.hash);
203
+ out.push({
204
+ path,
205
+ object,
206
+ originSignature: buildOriginSignature(object.mode, object.hash)
207
+ });
208
+ }
209
+ if (dirPath === "") setCachedBaseSnapshot(source, treeHash, createBaseSnapshotView(out));
210
+ return out;
211
+ }
212
+ function baseSnapshotViewForTree(source, treeHash) {
213
+ const cached = getCachedBaseSnapshot(source, treeHash);
214
+ if (cached !== null) return cached;
215
+ const view = createBaseSnapshotView(snapshotBaseTree(source, treeHash, ""));
216
+ setCachedBaseSnapshot(source, treeHash, view);
217
+ return view;
218
+ }
219
+ function snapshotCurrentTree(source, state, cache) {
220
+ const root = state.getNode("root");
221
+ if (root === null) throw new Error("Virtual workdir session is missing root node");
222
+ return snapshotCurrentNode(source, state, root, "", cache);
223
+ }
224
+ function snapshotCurrentNode(source, state, node, path, cache) {
225
+ if (node.state.kind === "directory") {
226
+ const out = [];
227
+ for (const child of getDirectoryChildrenView(source, state, node, path).children) {
228
+ const observedChild = observeListedDirectoryChild(state, path, child);
229
+ if (observedChild === null) continue;
230
+ out.push(...snapshotCurrentNode(source, state, observedChild.node, observedChild.path, cache));
231
+ }
232
+ return out;
233
+ }
234
+ return [snapshotCurrentLeafNode(source, {
235
+ path,
236
+ node
237
+ }, cache)];
238
+ }
239
+ function currentNodeHash(source, node, path, cache) {
240
+ if (node.state.kind === "file") {
241
+ if (node.state.content !== void 0) {
242
+ const cached = cache?.currentNodeHashes.get(node.id);
243
+ if (cached !== void 0) return cached;
244
+ const hash = hashObject("blob", node.state.content);
245
+ cache?.setCurrentNodeHash(node.id, hash);
246
+ return hash;
247
+ }
248
+ if (node.origin.kind === "repo-blob") return node.origin.hash;
249
+ }
250
+ if (node.state.kind === "symlink") {
251
+ if (node.state.target !== void 0) {
252
+ const cached = cache?.currentNodeHashes.get(node.id);
253
+ if (cached !== void 0) return cached;
254
+ const hash = hashObject("blob", node.state.target);
255
+ cache?.setCurrentNodeHash(node.id, hash);
256
+ return hash;
257
+ }
258
+ if (node.origin.kind === "repo-blob") return node.origin.hash;
259
+ }
260
+ if (node.origin.kind === "repo-blob") return hashObject("blob", readRepoBlobContent(source, node.origin.hash, path));
261
+ throw new Error(`Virtual workdir diff cannot resolve hash for path: ${path}`);
262
+ }
263
+ function computeChangeRecordForPath(source, state, path, previousRecord, cache) {
264
+ const baseEntry = baseSnapshotEntryAtPath(source, state.readBaseTree(), path);
265
+ const currentEntry = snapshotCurrentEntryAtPath(source, state, path, cache);
266
+ const preservedLineage = preserveLineageRecordForPath(path, previousRecord, currentEntry);
267
+ if (preservedLineage !== void 0) return preservedLineage;
268
+ if (baseEntry === null && currentEntry === null) return null;
269
+ if (baseEntry === null && currentEntry !== null) return createNormalizedChangeRecord(path, null, currentEntry.object);
270
+ if (baseEntry !== null && currentEntry === null) return createNormalizedChangeRecord(path, baseEntry.object, null);
271
+ if (baseEntry !== null && currentEntry !== null) {
272
+ if (isSameObject(baseEntry.object, currentEntry.object)) return null;
273
+ return createNormalizedChangeRecord(path, baseEntry.object, currentEntry.object);
274
+ }
275
+ throw new Error(`Unreachable change-record state at path: ${path}`);
276
+ }
277
+ function computeRenameRecordForPath(source, state, from, to, previousRecord, currentTarget) {
278
+ const derivedFromPrevious = deriveRenameRecordFromPreviousRecord(from, to, previousRecord, currentTarget);
279
+ if (derivedFromPrevious !== void 0) return derivedFromPrevious;
280
+ const baseEntry = baseSnapshotEntryAtPath(source, state.readBaseTree(), from);
281
+ if (baseEntry === null) return null;
282
+ return createNormalizedChangeRecord(to, baseEntry.object, currentTarget.object, createDiffSource("rename", from));
283
+ }
284
+ function deriveCopySource(from, sourceRecord, source, state) {
285
+ const fromRecordSource = sourceRecord?.source;
286
+ if (fromRecordSource !== null && fromRecordSource !== void 0) return createDiffSource("copy", fromRecordSource.path);
287
+ if (sourceRecord?.previous !== null || baseSnapshotEntryAtPath(source, state.readBaseTree(), from) !== null) return createDiffSource("copy", from);
288
+ return null;
289
+ }
290
+ function preserveLineageRecordForPath(path, previousRecord, currentEntry) {
291
+ if (previousRecord?.source?.kind === "rename" && previousRecord.previous !== null) {
292
+ if (currentEntry === null) return null;
293
+ return createNormalizedChangeRecord(path, previousRecord.previous, currentEntry.object, previousRecord.source);
294
+ }
295
+ if (previousRecord?.source?.kind === "copy") {
296
+ if (currentEntry === null) return null;
297
+ return createNormalizedChangeRecord(path, null, currentEntry.object, previousRecord.source);
298
+ }
299
+ }
300
+ function deriveRenameRecordFromPreviousRecord(from, to, previousRecord, currentTarget) {
301
+ if (previousRecord === null) return;
302
+ if (previousRecord.current === null) return null;
303
+ if (previousRecord.source !== null) return createNormalizedChangeRecord(to, previousRecord.previous, currentTarget.object, previousRecord.source);
304
+ if (previousRecord.previous === null) return createNormalizedChangeRecord(to, null, currentTarget.object);
305
+ return createNormalizedChangeRecord(to, previousRecord.previous, currentTarget.object, createDiffSource("rename", from));
306
+ }
307
+ function snapshotCurrentEntryAtPath(source, state, path, cache) {
308
+ const resolved = resolveCurrentLeafAtPath(source, state, path);
309
+ if (resolved === null) return null;
310
+ return snapshotCurrentLeafNode(source, resolved, cache);
311
+ }
312
+ function snapshotCurrentLeafNode(source, leaf, cache) {
313
+ if (leaf.node.state.kind === "directory") throw new Error(`snapshotCurrentLeafNode called on directory: ${leaf.path}`);
314
+ const hash = currentNodeHash(source, leaf.node, leaf.path, cache);
315
+ const object = createDiffObject(leaf.node.state.mode, hash);
316
+ return {
317
+ path: leaf.path,
318
+ object,
319
+ originSignature: leaf.node.origin.kind === "repo-blob" ? buildOriginSignature(leaf.node.origin.mode, leaf.node.origin.hash) : null
320
+ };
321
+ }
322
+ function createNormalizedChangeRecord(path, previous, current, source = null) {
323
+ return {
324
+ path,
325
+ previous,
326
+ current,
327
+ source
328
+ };
329
+ }
330
+ function createDiffSource(kind, path) {
331
+ return {
332
+ kind,
333
+ path
334
+ };
335
+ }
336
+ function createDiffObject(mode, hash) {
337
+ return {
338
+ kind: modeKind(mode),
339
+ mode,
340
+ hash
341
+ };
342
+ }
343
+ function diffChanges(previous, current) {
344
+ return {
345
+ kindChanged: previous.kind !== current.kind,
346
+ modeChanged: previous.mode !== current.mode,
347
+ contentChanged: previous.hash !== current.hash
348
+ };
349
+ }
350
+ function isSameObject(previous, current) {
351
+ return previous.kind === current.kind && previous.mode === current.mode && previous.hash === current.hash;
352
+ }
353
+ function normalizeBlobMode(mode) {
354
+ if (mode === "100755" || mode === "120000") return mode;
355
+ return "100644";
356
+ }
357
+ function buildOriginSignature(mode, hash) {
358
+ return `${mode}:${hash}`;
359
+ }
360
+ function getCachedBaseSnapshot(source, treeHash) {
361
+ return baseSnapshotCache.get(source)?.get(treeHash) ?? null;
362
+ }
363
+ function setCachedBaseSnapshot(source, treeHash, view) {
364
+ const cache = baseSnapshotCache.get(source) ?? /* @__PURE__ */ new Map();
365
+ cache.set(treeHash, view);
366
+ baseSnapshotCache.set(source, cache);
367
+ }
368
+ function createBaseSnapshotView(entries) {
369
+ return {
370
+ entries,
371
+ byPath: new Map(entries.map((entry) => [entry.path, entry]))
372
+ };
373
+ }
374
+ function baseSnapshotEntryAtPath(source, treeHash, path) {
375
+ return baseSnapshotViewForTree(source, treeHash).byPath.get(path) ?? null;
376
+ }
377
+ function indexDeletesBySignature(deletes) {
378
+ const out = /* @__PURE__ */ new Map();
379
+ for (const entry of deletes.values()) {
380
+ if (entry.previous === null) continue;
381
+ const signature = buildOriginSignature(entry.previous.mode, entry.previous.hash);
382
+ const list = out.get(signature) ?? [];
383
+ list.push(entry);
384
+ out.set(signature, list);
385
+ }
386
+ return out;
387
+ }
388
+ function findCopySource(baseEntries, current) {
389
+ if (current.originSignature === null) return null;
390
+ const source = baseEntries.filter((entry) => entry.originSignature === current.originSignature).sort((left, right) => left.path.localeCompare(right.path))[0];
391
+ if (source === void 0) return null;
392
+ return {
393
+ path: source.path,
394
+ entry: source
395
+ };
396
+ }
397
+ function modeKind(mode) {
398
+ return mode === "120000" ? "symlink" : "blob";
399
+ }
400
+ /**
401
+ * 从规范化变更索引导出当前 session 的最终 diff。
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * const diff = computeVirtualDiff(state);
406
+ * expect(diff.map((entry) => entry.path)).toEqual(["hello.txt"]);
407
+ * ```
408
+ */
409
+ function computeVirtualDiff(state) {
410
+ return exportVirtualDiffFromChangeRecords(state.listChangeRecords());
411
+ }
412
+ //#endregion
413
+ export { computeVirtualDiff, rebuildNormalizedChangeIndex, refreshChangeRecordForPath, replaceChangeRecords, rewriteChangeRecordForRename, writeChangeRecordForCopy };
@@ -40,25 +40,58 @@ interface VirtualDirEntry {
40
40
  readonly mode: string;
41
41
  }
42
42
  /**
43
- * 变更操作类型
44
- *
45
- * 用于 `listChanges()` 返回的变更记录。
43
+ * diff 中的对象描述
46
44
  */
47
- type VirtualChangeType = "add" | "modify" | "delete" | "rename" | "copy";
45
+ interface VirtualDiffObject {
46
+ /** 条目种类 */
47
+ readonly kind: "blob" | "symlink";
48
+ /** Git 文件模式 */
49
+ readonly mode: "100644" | "100755" | "120000";
50
+ /** 对象哈希 */
51
+ readonly hash: SHA1;
52
+ }
48
53
  /**
49
- * 单条变更记录
50
- *
51
- * 由 `listChanges()` 返回,描述 session 内的单次操作。
52
- * 变更记录是会话内调试/测试辅助,不保证是最小 diff。
54
+ * rename/copy 来源描述
53
55
  */
54
- interface VirtualChange {
55
- /** 操作路径 */
56
+ interface VirtualDiffSource {
57
+ /** 来源类型 */
58
+ readonly kind: "rename" | "copy";
59
+ /** 来源路径 */
56
60
  readonly path: string;
57
- /** 变更操作类型 */
58
- readonly type: VirtualChangeType;
59
- /** rename/copy 操作的源路径(其他操作为 undefined) */
60
- readonly oldPath?: string;
61
61
  }
62
+ /**
63
+ * 同路径更新的变化维度
64
+ */
65
+ interface VirtualDiffChanges {
66
+ /** 条目种类是否变化 */
67
+ readonly kindChanged: boolean;
68
+ /** mode 是否变化 */
69
+ readonly modeChanged: boolean;
70
+ /** 内容哈希是否变化 */
71
+ readonly contentChanged: boolean;
72
+ }
73
+ /**
74
+ * 单条 diff 条目
75
+ *
76
+ * 仅描述最终状态,不表达完整会话内操作历史。
77
+ */
78
+ type VirtualDiffEntry = {
79
+ /** 新建路径 */readonly kind: "create"; /** 当前路径 */
80
+ readonly path: string; /** 当前对象 */
81
+ readonly current: VirtualDiffObject; /** rename/copy 的来源 */
82
+ readonly source?: VirtualDiffSource;
83
+ } | {
84
+ /** 删除路径 */readonly kind: "remove"; /** 当前路径 */
85
+ readonly path: string; /** 删除前对象 */
86
+ readonly previous: VirtualDiffObject;
87
+ } | {
88
+ /** 同路径更新 */readonly kind: "update"; /** 当前路径 */
89
+ readonly path: string; /** 更新前对象 */
90
+ readonly previous: VirtualDiffObject; /** 更新后对象 */
91
+ readonly current: VirtualDiffObject; /** 变化维度 */
92
+ readonly changes: VirtualDiffChanges; /** rename/copy 的来源 */
93
+ readonly source?: VirtualDiffSource;
94
+ };
62
95
  /**
63
96
  * 创建 VirtualWorkdirSession 的选项
64
97
  */
@@ -139,6 +172,12 @@ interface VirtualWorkdirSession {
139
172
  * 对纯新建节点抛出 VirtualRevertNotSupportedError。
140
173
  */
141
174
  revert(path: string): void;
175
+ /**
176
+ * 读取最终 diff
177
+ *
178
+ * 输出按路径稳定排序,仅包含文件与符号链接条目。
179
+ */
180
+ diff(): VirtualDiffEntry[];
142
181
  /**
143
182
  * 导出当前 overlay 为新 tree
144
183
  *
@@ -152,13 +191,6 @@ interface VirtualWorkdirSession {
152
191
  * 丢弃全部 overlay 与变更历史。
153
192
  */
154
193
  reset(baseTree: SHA1): void;
155
- /**
156
- * 列出会话内的变更记录
157
- *
158
- * 是会话内调试/测试辅助,不保证是最小 diff 引擎。
159
- * 输出稳定、测试可断言即可。
160
- */
161
- listChanges(): VirtualChange[];
162
194
  }
163
195
  /**
164
196
  * VirtualWorkdirBackend
@@ -185,4 +217,4 @@ interface VirtualWorkdirBackend {
185
217
  listSessions(): VirtualWorkdirSessionId[];
186
218
  }
187
219
  //#endregion
188
- export { CreateVirtualWorkdirSessionOptions, VirtualChange, VirtualChangeType, VirtualDirEntry, VirtualEntryKind, VirtualEntryStat, VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError, VirtualWorkdirBackend, VirtualWorkdirSession, VirtualWorkdirSessionId };
220
+ export { CreateVirtualWorkdirSessionOptions, VirtualDiffChanges, VirtualDiffEntry, VirtualDiffObject, VirtualDiffSource, VirtualDirEntry, VirtualEntryKind, VirtualEntryStat, VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError, VirtualWorkdirBackend, VirtualWorkdirSession, VirtualWorkdirSessionId };