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.
@@ -0,0 +1,197 @@
1
+ import { VirtualNotDirectoryError } from "../core/errors.mjs";
2
+ import { ensureNodeFromTreeEntry, joinChildPath, listDirectoryChildren } from "./session-internal.mjs";
3
+ //#region src/workdir/directory-view.ts
4
+ /**
5
+ * Virtual Workdir 目录展开、观察与编译计划
6
+ *
7
+ * 从 session-internal.ts 拆分,聚焦以下职责:
8
+ * - 目录子项观察(observeDirectoryChildren / observeListedDirectoryChild / observeNamedDirectoryChild)
9
+ * - origin 按名查询视图(createNamedOriginChildLookup)
10
+ * - 受影响子项编译计划(planAffectedDirectoryChildren)
11
+ */
12
+ /**
13
+ * 在不展开整个目录列表的前提下,按名称定向解析单个子节点。
14
+ *
15
+ * 优先读取 overlay 绑定;若 overlay 未覆盖,再按需从 origin 条目懒注册。
16
+ */
17
+ function resolveNamedChild(state, dirNode, originLookup, name) {
18
+ if (dirNode.state.kind !== "directory") return {
19
+ found: false,
20
+ node: null
21
+ };
22
+ const overlayNodeId = dirNode.state.overlay.addedEntries.get(name);
23
+ if (overlayNodeId !== void 0) {
24
+ const overlayNode = state.getNode(overlayNodeId);
25
+ return overlayNode === null ? {
26
+ found: false,
27
+ node: null
28
+ } : {
29
+ found: true,
30
+ node: overlayNode
31
+ };
32
+ }
33
+ if (dirNode.state.overlay.deletedNames.has(name)) return {
34
+ found: false,
35
+ node: null
36
+ };
37
+ const originEntry = originLookup.get(name);
38
+ if (originEntry === void 0) return {
39
+ found: false,
40
+ node: null
41
+ };
42
+ const originNodeId = ensureNodeFromTreeEntry(state, originEntry);
43
+ const originNode = state.getNode(originNodeId);
44
+ return originNode === null ? {
45
+ found: false,
46
+ node: null
47
+ } : {
48
+ found: true,
49
+ node: originNode
50
+ };
51
+ }
52
+ /**
53
+ * 基于已展开的目录子项读取当前节点与完整路径。
54
+ *
55
+ * 节点不存在时返回 `null`。
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const observed = observeListedDirectoryChild(state, "", child);
60
+ * expect(observed?.path).toBe("a.txt");
61
+ * ```
62
+ */
63
+ function observeListedDirectoryChild(state, dirPath, child) {
64
+ const node = state.getNode(child.nodeId);
65
+ if (node === null) return null;
66
+ return {
67
+ name: child.name,
68
+ path: joinChildPath(dirPath, child.name),
69
+ child,
70
+ node
71
+ };
72
+ }
73
+ /**
74
+ * 基于目录节点与子项名称定向读取当前节点与完整路径。
75
+ *
76
+ * 适用于已持有 origin lookup、但不想手工重复拼装 `{ name, path, node }`
77
+ * 局部协议的场景。
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * const observed = observeNamedDirectoryChild(state, dirNode, "", lookup, "a.txt");
82
+ * expect(observed?.path).toBe("a.txt");
83
+ * ```
84
+ */
85
+ function observeNamedDirectoryChild(state, dirNode, dirPath, originLookup, name) {
86
+ if (dirNode.state.kind !== "directory") return null;
87
+ const resolved = resolveNamedChild(state, dirNode, originLookup, name);
88
+ if (!resolved.found) return null;
89
+ return {
90
+ name,
91
+ path: joinChildPath(dirPath, name),
92
+ node: resolved.node
93
+ };
94
+ }
95
+ /**
96
+ * 观察目录当前子项,归纳直接受影响名字与更深层脏项数。
97
+ *
98
+ * 目录自身 overlay 的 add/delete 会直接计入 `affectedNames`;
99
+ * 子目录是否脏、叶子节点是否脏由调用方回调决定。
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const observed = observeDirectoryChildren(source, state, node, "", {
104
+ * onDirectoryChild() {
105
+ * return 0;
106
+ * },
107
+ * isLeafChildDirty() {
108
+ * return false;
109
+ * },
110
+ * });
111
+ * expect(observed.affectedNames.size).toBeGreaterThanOrEqual(0);
112
+ * ```
113
+ */
114
+ function observeDirectoryChildren(source, state, dirNode, dirPath, options) {
115
+ if (dirNode.state.kind !== "directory") throw new VirtualNotDirectoryError(dirPath);
116
+ const affectedNames = /* @__PURE__ */ new Set([...dirNode.state.overlay.addedEntries.keys(), ...dirNode.state.overlay.deletedNames.values()]);
117
+ let dirtyDescendantCount = 0;
118
+ const children = listDirectoryChildren(source, state, dirNode, dirPath);
119
+ for (const child of children) {
120
+ const observedChild = observeListedDirectoryChild(state, dirPath, child);
121
+ if (observedChild === null) continue;
122
+ if (observedChild.node.state.kind === "directory") {
123
+ const childDirtyCount = options.onDirectoryChild({
124
+ name: observedChild.name,
125
+ path: observedChild.path,
126
+ node: observedChild.node
127
+ });
128
+ if (childDirtyCount > 0) {
129
+ affectedNames.add(observedChild.name);
130
+ dirtyDescendantCount += childDirtyCount;
131
+ }
132
+ continue;
133
+ }
134
+ if (options.isLeafChildDirty({
135
+ name: observedChild.name,
136
+ path: observedChild.path,
137
+ node: observedChild.node
138
+ })) affectedNames.add(observedChild.name);
139
+ }
140
+ return {
141
+ affectedNames,
142
+ dirtyDescendantCount
143
+ };
144
+ }
145
+ /**
146
+ * 为目录 origin 条目创建按名查询视图。
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * const lookup = createNamedOriginChildLookup(tree.entries);
151
+ * expect(lookup.has("a.txt")).toBe(true);
152
+ * ```
153
+ */
154
+ function createNamedOriginChildLookup(entries) {
155
+ const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
156
+ return {
157
+ entries,
158
+ has(name) {
159
+ return entriesByName.has(name);
160
+ },
161
+ get(name) {
162
+ return entriesByName.get(name);
163
+ }
164
+ };
165
+ }
166
+ /**
167
+ * 基于 origin 顺序和受影响名字生成目录子项编译计划。
168
+ *
169
+ * 1. origin 中未受影响的条目保持原顺序并标记为直接复用
170
+ * 2. origin 中受影响的条目保持原顺序并标记为需要编译
171
+ * 3. 不在 origin 中的受影响名字补在末尾
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const plan = planAffectedDirectoryChildren(lookup, new Set(["b.txt", "c.txt"]));
176
+ * expect(plan.map((entry) => entry.name)).toEqual(["a.txt", "b.txt", "c.txt"]);
177
+ * ```
178
+ */
179
+ function planAffectedDirectoryChildren(originLookup, affectedNames) {
180
+ const out = [];
181
+ for (const entry of originLookup.entries) out.push({
182
+ name: entry.name,
183
+ originEntry: entry,
184
+ shouldCompile: affectedNames.has(entry.name)
185
+ });
186
+ for (const name of affectedNames) {
187
+ if (originLookup.has(name)) continue;
188
+ out.push({
189
+ name,
190
+ originEntry: null,
191
+ shouldCompile: true
192
+ });
193
+ }
194
+ return out;
195
+ }
196
+ //#endregion
197
+ export { createNamedOriginChildLookup, observeDirectoryChildren, observeListedDirectoryChild, observeNamedDirectoryChild, planAffectedDirectoryChildren };
@@ -0,0 +1,73 @@
1
+ import { getRootNode } from "./session-internal.mjs";
2
+ import { observeDirectoryChildren } from "./directory-view.mjs";
3
+ import { createDirtyDirSummary } from "./dirty-dir.mjs";
4
+ //#region src/workdir/dirty-dir-plan.ts
5
+ /**
6
+ * Virtual Workdir dirty-dir summary 重建策略
7
+ *
8
+ * 把 session 写路径里的脏目录摘要清理与重建逻辑集中到单独模块,
9
+ * 让 session.ts 更接近纯编排层。
10
+ */
11
+ /**
12
+ * 创建 dirty-dir summary 策略器。
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * const planner = createDirtyDirPlanner(source, state);
17
+ * planner.rebuild(["src/index.ts"]);
18
+ * ```
19
+ */
20
+ function createDirtyDirPlanner(source, state) {
21
+ return {
22
+ clear() {
23
+ for (const summary of state.listDirtyDirSummaries()) state.deleteDirtyDirSummary(summary.path);
24
+ },
25
+ rebuild(touchedPaths) {
26
+ const nextSummaries = /* @__PURE__ */ new Map();
27
+ const invalidatedDirPaths = collectInvalidatedSummaryPaths(touchedPaths);
28
+ const visitDirectory = (node, dirPath) => {
29
+ if (node.state.kind !== "directory") throw new Error(`rebuildDirtyDirectorySummaries: '${dirPath}' is not a directory`);
30
+ const { affectedNames, dirtyDescendantCount } = observeDirectoryChildren(source, state, node, dirPath, {
31
+ onDirectoryChild(child) {
32
+ return visitDirectory(child.node, child.path);
33
+ },
34
+ isLeafChildDirty(child) {
35
+ return state.getChangeRecord(child.path) !== null;
36
+ }
37
+ });
38
+ if (affectedNames.size === 0 && dirtyDescendantCount === 0) return 0;
39
+ const existing = state.getDirtyDirSummary(dirPath);
40
+ const preserveHash = existing !== null && !invalidatedDirPaths.has(dirPath);
41
+ nextSummaries.set(dirPath, {
42
+ ...createDirtyDirSummary(dirPath, Array.from(affectedNames)),
43
+ dirtyDescendantCount,
44
+ currentTreeHash: preserveHash ? existing.currentTreeHash : null,
45
+ hashState: preserveHash ? existing.hashState : "stale"
46
+ });
47
+ return affectedNames.size + dirtyDescendantCount;
48
+ };
49
+ visitDirectory(getRootNode(state), "");
50
+ for (const summary of state.listDirtyDirSummaries()) if (!nextSummaries.has(summary.path)) state.deleteDirtyDirSummary(summary.path);
51
+ for (const summary of nextSummaries.values()) state.setDirtyDirSummary(summary);
52
+ }
53
+ };
54
+ }
55
+ function collectInvalidatedSummaryPaths(paths) {
56
+ const out = /* @__PURE__ */ new Set();
57
+ for (const path of paths) {
58
+ out.add(path);
59
+ let cursor = path;
60
+ while (true) {
61
+ const slashIndex = cursor.lastIndexOf("/");
62
+ if (slashIndex < 0) {
63
+ out.add("");
64
+ break;
65
+ }
66
+ cursor = cursor.slice(0, slashIndex);
67
+ out.add(cursor);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ //#endregion
73
+ export { createDirtyDirPlanner };
@@ -0,0 +1,28 @@
1
+ import { SHA1 } from "../core/types.mjs";
2
+
3
+ //#region src/workdir/dirty-dir.d.ts
4
+ /**
5
+ * 目录摘要中的 hash 状态
6
+ */
7
+ type DirtyDirHashState = "stale" | "materialized";
8
+ /**
9
+ * 脏目录摘要
10
+ */
11
+ interface DirtyDirSummary {
12
+ /** 目录路径,根目录为 "" */
13
+ readonly path: string;
14
+ /** 当前目录是否脏 */
15
+ readonly isDirty: boolean;
16
+ /** 当前目录下受影响的直接子项数量 */
17
+ readonly dirtyEntryCount: number;
18
+ /** 当前目录更深层受影响子项数量 */
19
+ readonly dirtyDescendantCount: number;
20
+ /** 当前目录下受影响的直接子项名称 */
21
+ readonly affectedNames: readonly string[];
22
+ /** 当前物化 tree hash;未计算时为 null */
23
+ readonly currentTreeHash: SHA1 | null;
24
+ /** 当前 tree hash 的可信状态 */
25
+ readonly hashState: DirtyDirHashState;
26
+ }
27
+ //#endregion
28
+ export { DirtyDirSummary };
@@ -0,0 +1,32 @@
1
+ //#region src/workdir/dirty-dir.ts
2
+ /**
3
+ * 创建脏目录摘要记录
4
+ */
5
+ function createDirtyDirSummary(path, affectedNames = []) {
6
+ const names = [...new Set(affectedNames)].sort((left, right) => left.localeCompare(right));
7
+ return {
8
+ path,
9
+ isDirty: true,
10
+ dirtyEntryCount: names.length,
11
+ dirtyDescendantCount: 0,
12
+ affectedNames: names,
13
+ currentTreeHash: null,
14
+ hashState: "stale"
15
+ };
16
+ }
17
+ /**
18
+ * 将目录摘要标记为已物化 tree hash。
19
+ */
20
+ function materializeDirtyDirSummary(current, path, treeHash) {
21
+ return {
22
+ path,
23
+ isDirty: true,
24
+ dirtyEntryCount: current?.dirtyEntryCount ?? 0,
25
+ dirtyDescendantCount: current?.dirtyDescendantCount ?? 0,
26
+ affectedNames: [...current?.affectedNames ?? []].sort((left, right) => left.localeCompare(right)),
27
+ currentTreeHash: treeHash,
28
+ hashState: "materialized"
29
+ };
30
+ }
31
+ //#endregion
32
+ export { createDirtyDirSummary, materializeDirtyDirSummary };
@@ -1,3 +1,4 @@
1
+ import { sha1 } from "../core/types.mjs";
1
2
  import { createRootDirectoryNode } from "./nodes.mjs";
2
3
  import { createVirtualWorkdirSessionId } from "./session-id.mjs";
3
4
  import { openVirtualWorkdirSession } from "./session.mjs";
@@ -7,7 +8,7 @@ import { dirname, join } from "node:path";
7
8
  /**
8
9
  * Virtual Workdir 文件系统 backend
9
10
  */
10
- const FILE_WORKDIR_MANIFEST_VERSION = 1;
11
+ const FILE_WORKDIR_MANIFEST_VERSION = 5;
11
12
  const FILE_WORKDIR_TRANSACTION_SNAPSHOT_SUFFIX = ".txn-snapshot";
12
13
  /**
13
14
  * 创建基于文件系统目录的 Virtual Workdir backend
@@ -129,19 +130,46 @@ function createFileVirtualWorkdirStateStore(sessionsRoot, sessionId) {
129
130
  };
130
131
  });
131
132
  },
132
- appendChange(record) {
133
+ listChangeRecords() {
134
+ return readManifest(manifestPath).changeRecords.map(restoreChangeRecord);
135
+ },
136
+ getChangeRecord(path) {
137
+ return readManifest(manifestPath).changeRecords.map(restoreChangeRecord).find((record) => record.path === path) ?? null;
138
+ },
139
+ setChangeRecord(record) {
140
+ updateManifest(sessionDir, (manifest) => {
141
+ const others = manifest.changeRecords.filter((item) => item.path !== record.path);
142
+ return {
143
+ ...manifest,
144
+ changeRecords: [...others, serializeChangeRecord(record)].sort((left, right) => left.path.localeCompare(right.path))
145
+ };
146
+ });
147
+ },
148
+ deleteChangeRecord(path) {
133
149
  updateManifest(sessionDir, (manifest) => ({
134
150
  ...manifest,
135
- changes: [...manifest.changes, record]
151
+ changeRecords: manifest.changeRecords.filter((item) => item.path !== path)
136
152
  }));
137
153
  },
138
- listChangeRecords() {
139
- return readManifest(manifestPath).changes;
154
+ listDirtyDirSummaries() {
155
+ return readManifest(manifestPath).dirtyDirSummaries.map(restoreDirtyDirSummary);
156
+ },
157
+ getDirtyDirSummary(path) {
158
+ return readManifest(manifestPath).dirtyDirSummaries.map(restoreDirtyDirSummary).find((summary) => summary.path === path) ?? null;
159
+ },
160
+ setDirtyDirSummary(summary) {
161
+ updateManifest(sessionDir, (manifest) => {
162
+ const others = manifest.dirtyDirSummaries.filter((item) => item.path !== summary.path);
163
+ return {
164
+ ...manifest,
165
+ dirtyDirSummaries: [...others, serializeDirtyDirSummary(summary)].sort((left, right) => left.path.localeCompare(right.path))
166
+ };
167
+ });
140
168
  },
141
- clearChanges() {
169
+ deleteDirtyDirSummary(path) {
142
170
  updateManifest(sessionDir, (manifest) => ({
143
171
  ...manifest,
144
- changes: []
172
+ dirtyDirSummaries: manifest.dirtyDirSummaries.filter((item) => item.path !== path)
145
173
  }));
146
174
  },
147
175
  reset(baseTree) {
@@ -197,8 +225,9 @@ function createManifest(baseTree, nodes) {
197
225
  return {
198
226
  formatVersion: FILE_WORKDIR_MANIFEST_VERSION,
199
227
  baseTree,
200
- changes: [],
201
- nodes
228
+ nodes,
229
+ changeRecords: [],
230
+ dirtyDirSummaries: []
202
231
  };
203
232
  }
204
233
  function updateManifest(sessionDir, updater) {
@@ -217,8 +246,9 @@ function readManifest(manifestPath) {
217
246
  }
218
247
  function validateManifest(manifest) {
219
248
  if (typeof manifest.baseTree !== "string" || manifest.baseTree.length === 0) throw new Error("Invalid virtual workdir manifest baseTree");
220
- if (!Array.isArray(manifest.changes)) throw new Error("Invalid virtual workdir manifest changes");
221
249
  if (typeof manifest.nodes !== "object" || manifest.nodes === null || Array.isArray(manifest.nodes)) throw new Error("Invalid virtual workdir manifest nodes");
250
+ if (!Array.isArray(manifest.changeRecords)) throw new Error("Invalid virtual workdir manifest changeRecords");
251
+ if (!Array.isArray(manifest.dirtyDirSummaries)) throw new Error("Invalid virtual workdir manifest dirtyDirSummaries");
222
252
  if (manifest.formatVersion !== FILE_WORKDIR_MANIFEST_VERSION) throw new Error(`Unsupported virtual workdir file manifest version: expected ${FILE_WORKDIR_MANIFEST_VERSION}, got ${manifest.formatVersion}`);
223
253
  }
224
254
  function persistNode(contentDir, node) {
@@ -339,6 +369,62 @@ function restoreOrigin(record) {
339
369
  hash: origin.hash
340
370
  };
341
371
  }
372
+ function serializeChangeRecord(record) {
373
+ return {
374
+ path: record.path,
375
+ previous: record.previous,
376
+ current: record.current,
377
+ source: record.source
378
+ };
379
+ }
380
+ function restoreChangeRecord(record) {
381
+ return {
382
+ path: record.path,
383
+ previous: record.previous === null ? null : {
384
+ ...record.previous,
385
+ hash: record.previous.hash
386
+ },
387
+ current: record.current === null ? null : {
388
+ ...record.current,
389
+ hash: record.current.hash
390
+ },
391
+ source: record.source
392
+ };
393
+ }
394
+ function serializeDirtyDirSummary(summary) {
395
+ return {
396
+ path: summary.path,
397
+ isDirty: summary.isDirty,
398
+ dirtyEntryCount: summary.dirtyEntryCount,
399
+ dirtyDescendantCount: summary.dirtyDescendantCount,
400
+ affectedNames: [...summary.affectedNames],
401
+ currentTreeHash: summary.currentTreeHash,
402
+ hashState: summary.hashState
403
+ };
404
+ }
405
+ function restoreDirtyDirSummary(summary) {
406
+ return {
407
+ path: summary.path,
408
+ isDirty: summary.isDirty,
409
+ dirtyEntryCount: readDirtyDirCount(summary.dirtyEntryCount, "dirtyEntryCount"),
410
+ dirtyDescendantCount: readDirtyDirCount(summary.dirtyDescendantCount, "dirtyDescendantCount"),
411
+ affectedNames: readDirtyDirAffectedNames(summary.affectedNames),
412
+ currentTreeHash: summary.currentTreeHash === null ? null : sha1(summary.currentTreeHash),
413
+ hashState: readDirtyDirHashState(summary.hashState)
414
+ };
415
+ }
416
+ function readDirtyDirCount(raw, field) {
417
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) throw new Error(`Invalid file workdir dirty dir summary ${field}`);
418
+ return raw;
419
+ }
420
+ function readDirtyDirAffectedNames(raw) {
421
+ if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string")) throw new Error("Invalid file workdir dirty dir summary affectedNames");
422
+ return [...raw].sort((left, right) => left.localeCompare(right));
423
+ }
424
+ function readDirtyDirHashState(raw) {
425
+ if (raw === "stale" || raw === "materialized") return raw;
426
+ throw new Error(`Invalid file workdir dirty dir summary hashState: ${String(raw)}`);
427
+ }
342
428
  function readPayload(contentDir, payloadRef) {
343
429
  const path = getContentPath(contentDir, payloadRef);
344
430
  if (!existsSync(path)) throw new Error(`Virtual workdir payload not found: ${payloadRef}`);
@@ -1,4 +1,3 @@
1
- import { createVirtualChangeLog } from "./change-log.mjs";
2
1
  import { VIRTUAL_ROOT_NODE_ID } from "./ids.mjs";
3
2
  import { createRootDirectoryNode } from "./nodes.mjs";
4
3
  import { createVirtualWorkdirSessionId } from "./session-id.mjs";
@@ -20,7 +19,8 @@ function createVirtualWorkdirMemoryStateStore(baseTree) {
20
19
  const state = {
21
20
  baseTree,
22
21
  nodes: /* @__PURE__ */ new Map(),
23
- changeLog: createVirtualChangeLog()
22
+ changeRecords: /* @__PURE__ */ new Map(),
23
+ dirtyDirSummaries: /* @__PURE__ */ new Map()
24
24
  };
25
25
  resetState(state, baseTree);
26
26
  return {
@@ -49,14 +49,29 @@ function createVirtualWorkdirMemoryStateStore(baseTree) {
49
49
  deleteNode(id) {
50
50
  state.nodes.delete(id);
51
51
  },
52
- appendChange(record) {
53
- state.changeLog.append(record);
54
- },
55
52
  listChangeRecords() {
56
- return state.changeLog.snapshot();
53
+ return Array.from(state.changeRecords.values()).sort((left, right) => left.path.localeCompare(right.path));
54
+ },
55
+ getChangeRecord(path) {
56
+ return state.changeRecords.get(path) ?? null;
57
+ },
58
+ setChangeRecord(record) {
59
+ state.changeRecords.set(record.path, record);
60
+ },
61
+ deleteChangeRecord(path) {
62
+ state.changeRecords.delete(path);
63
+ },
64
+ listDirtyDirSummaries() {
65
+ return Array.from(state.dirtyDirSummaries.values()).sort((left, right) => left.path.localeCompare(right.path));
66
+ },
67
+ getDirtyDirSummary(path) {
68
+ return state.dirtyDirSummaries.get(path) ?? null;
69
+ },
70
+ setDirtyDirSummary(summary) {
71
+ state.dirtyDirSummaries.set(summary.path, summary);
57
72
  },
58
- clearChanges() {
59
- state.changeLog.clear();
73
+ deleteDirtyDirSummary(path) {
74
+ state.dirtyDirSummaries.delete(path);
60
75
  },
61
76
  reset(nextBaseTree) {
62
77
  resetState(state, nextBaseTree);
@@ -99,8 +114,9 @@ function createMemoryVirtualWorkdirBackend() {
99
114
  function resetState(state, baseTree) {
100
115
  state.baseTree = baseTree;
101
116
  state.nodes.clear();
117
+ state.changeRecords.clear();
118
+ state.dirtyDirSummaries.clear();
102
119
  state.nodes.set(VIRTUAL_ROOT_NODE_ID, createRootDirectoryNode(baseTree));
103
- state.changeLog.clear();
104
120
  }
105
121
  function snapshotState(state) {
106
122
  const nodes = /* @__PURE__ */ new Map();
@@ -108,15 +124,18 @@ function snapshotState(state) {
108
124
  return {
109
125
  baseTree: state.baseTree,
110
126
  nodes,
111
- changes: state.changeLog.snapshot()
127
+ changeRecords: new Map(state.changeRecords),
128
+ dirtyDirSummaries: new Map(state.dirtyDirSummaries)
112
129
  };
113
130
  }
114
131
  function restoreState(state, snapshot) {
115
132
  state.baseTree = snapshot.baseTree;
116
133
  state.nodes.clear();
134
+ state.changeRecords.clear();
135
+ state.dirtyDirSummaries.clear();
117
136
  for (const [nodeId, node] of snapshot.nodes) state.nodes.set(nodeId, cloneSessionNode(node));
118
- state.changeLog.clear();
119
- for (const record of snapshot.changes) state.changeLog.append(record);
137
+ for (const [path, record] of snapshot.changeRecords) state.changeRecords.set(path, record);
138
+ for (const [path, summary] of snapshot.dirtyDirSummaries) state.dirtyDirSummaries.set(path, summary);
120
139
  }
121
140
  function cloneSessionNode(node) {
122
141
  if (node.state.kind === "directory") return {
@@ -29,12 +29,6 @@ function nodeHasRepoOrigin(node) {
29
29
  return node.origin.kind === "repo-tree" || node.origin.kind === "repo-blob";
30
30
  }
31
31
  /**
32
- * 目录 overlay 是否有 session 层修改
33
- */
34
- function isDirectoryOverlayDirty(overlay) {
35
- return overlay.addedEntries.size > 0 || overlay.deletedNames.size > 0;
36
- }
37
- /**
38
32
  * 将节点状态恢复到 origin 语义(丢弃 materialize / 目录 overlay)
39
33
  *
40
34
  * 无 repo origin 时返回原状态引用,由上层决定是否抛错。
@@ -109,4 +103,4 @@ function cloneSessionNodeForCopy(source, newId) {
109
103
  };
110
104
  }
111
105
  //#endregion
112
- export { cloneSessionNodeForCopy, createRootDirectoryNode, isDirectoryOverlayDirty, revertNodeState };
106
+ export { cloneSessionNodeForCopy, createRootDirectoryNode, revertNodeState };