nano-git 0.3.0 → 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 +6 -0
- package/dist/workdir/change-index-plan.mjs +76 -0
- package/dist/workdir/change-index.d.mts +21 -0
- package/dist/workdir/change-index.mjs +413 -0
- package/dist/workdir/core.d.mts +54 -22
- package/dist/workdir/directory-view.mjs +197 -0
- package/dist/workdir/dirty-dir-plan.mjs +73 -0
- package/dist/workdir/dirty-dir.d.mts +28 -0
- package/dist/workdir/dirty-dir.mjs +32 -0
- package/dist/workdir/file-backend.mjs +96 -10
- package/dist/workdir/memory-backend.mjs +31 -12
- package/dist/workdir/nodes.mjs +1 -7
- package/dist/workdir/session-internal.mjs +216 -9
- package/dist/workdir/session-transaction.mjs +115 -0
- package/dist/workdir/session.mjs +103 -230
- package/dist/workdir/sqlite-backend.mjs +149 -48
- package/dist/workdir/state-store.d.mts +19 -8
- package/dist/workdir/write-tree.mjs +104 -40
- package/package.json +2 -1
- package/dist/workdir/change-log.d.mts +0 -25
- package/dist/workdir/change-log.mjs +0 -69
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
changeRecords: manifest.changeRecords.filter((item) => item.path !== path)
|
|
136
152
|
}));
|
|
137
153
|
},
|
|
138
|
-
|
|
139
|
-
return readManifest(manifestPath).
|
|
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
|
-
|
|
169
|
+
deleteDirtyDirSummary(path) {
|
|
142
170
|
updateManifest(sessionDir, (manifest) => ({
|
|
143
171
|
...manifest,
|
|
144
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
59
|
-
state.
|
|
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
|
-
|
|
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.
|
|
119
|
-
for (const
|
|
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 {
|
package/dist/workdir/nodes.mjs
CHANGED
|
@@ -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,
|
|
106
|
+
export { cloneSessionNodeForCopy, createRootDirectoryNode, revertNodeState };
|