nano-git 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/errors.d.mts +71 -1
- package/dist/core/errors.mjs +99 -1
- package/dist/errors.d.mts +2 -2
- package/dist/errors.mjs +2 -2
- package/dist/log/index.d.mts +3 -0
- package/dist/log/index.mjs +2 -0
- package/dist/log/types.d.mts +71 -0
- package/dist/log/walk.d.mts +37 -0
- package/dist/log/walk.mjs +274 -0
- package/dist/workdir/change-log.d.mts +25 -0
- package/dist/workdir/change-log.mjs +69 -0
- package/dist/workdir/core.d.mts +188 -0
- package/dist/workdir/core.mjs +2 -0
- package/dist/workdir/file-backend.d.mts +22 -0
- package/dist/workdir/file-backend.mjs +366 -0
- package/dist/workdir/file.d.mts +2 -0
- package/dist/workdir/file.mjs +2 -0
- package/dist/workdir/ids.d.mts +7 -0
- package/dist/workdir/ids.mjs +20 -0
- package/dist/workdir/memory-backend.d.mts +17 -0
- package/dist/workdir/memory-backend.mjs +159 -0
- package/dist/workdir/memory.d.mts +3 -0
- package/dist/workdir/memory.mjs +3 -0
- package/dist/workdir/nodes.d.mts +57 -0
- package/dist/workdir/nodes.mjs +112 -0
- package/dist/workdir/origin.mjs +55 -0
- package/dist/workdir/overlay.d.mts +14 -0
- package/dist/workdir/overlay.mjs +95 -0
- package/dist/workdir/path.mjs +70 -0
- package/dist/workdir/session-id.mjs +18 -0
- package/dist/workdir/session-internal.mjs +141 -0
- package/dist/workdir/session.d.mts +30 -0
- package/dist/workdir/session.mjs +407 -0
- package/dist/workdir/sqlite-backend.d.mts +30 -0
- package/dist/workdir/sqlite-backend.mjs +363 -0
- package/dist/workdir/sqlite.d.mts +2 -0
- package/dist/workdir/sqlite.mjs +2 -0
- package/dist/workdir/state-store.d.mts +44 -0
- package/dist/workdir/write-tree.mjs +97 -0
- package/package.json +13 -3
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { SHA1 } from "../core/types.mjs";
|
|
2
|
+
import { ObjectDatabase } from "../core/types/odb.mjs";
|
|
3
|
+
import { VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError } from "../core/errors.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/workdir/core.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* 虚拟工作目录条目种类
|
|
8
|
+
*
|
|
9
|
+
* - `"blob"`: 普通文件或可执行文件(mode 100644 / 100755)
|
|
10
|
+
* - `"tree"`: 目录(mode 40000)
|
|
11
|
+
* - `"symlink"`: 符号链接(mode 120000)
|
|
12
|
+
*/
|
|
13
|
+
type VirtualEntryKind = "blob" | "tree" | "symlink";
|
|
14
|
+
/**
|
|
15
|
+
* 虚拟路径状态信息
|
|
16
|
+
*
|
|
17
|
+
* 由 `stat()` 返回,描述路径对应的节点属性。
|
|
18
|
+
*/
|
|
19
|
+
interface VirtualEntryStat {
|
|
20
|
+
/** 条目种类 */
|
|
21
|
+
readonly kind: VirtualEntryKind;
|
|
22
|
+
/** Git 文件模式(如 "100644"、"100755"、"40000"、"120000") */
|
|
23
|
+
readonly mode: string;
|
|
24
|
+
/** 文件大小(对 blob 和 symlink 有效;目录返回 0) */
|
|
25
|
+
readonly size: number;
|
|
26
|
+
/** 内容哈希(对 repo-backed 节点返回 origin 哈希;CoW 节点可能为 null) */
|
|
27
|
+
readonly hash: SHA1 | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 目录条目
|
|
31
|
+
*
|
|
32
|
+
* 由 `readdir()` 返回,描述目录下的子条目。
|
|
33
|
+
*/
|
|
34
|
+
interface VirtualDirEntry {
|
|
35
|
+
/** 条目名称(不含路径前缀) */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** 条目种类 */
|
|
38
|
+
readonly kind: VirtualEntryKind;
|
|
39
|
+
/** Git 文件模式 */
|
|
40
|
+
readonly mode: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 变更操作类型
|
|
44
|
+
*
|
|
45
|
+
* 用于 `listChanges()` 返回的变更记录。
|
|
46
|
+
*/
|
|
47
|
+
type VirtualChangeType = "add" | "modify" | "delete" | "rename" | "copy";
|
|
48
|
+
/**
|
|
49
|
+
* 单条变更记录
|
|
50
|
+
*
|
|
51
|
+
* 由 `listChanges()` 返回,描述 session 内的单次操作。
|
|
52
|
+
* 变更记录是会话内调试/测试辅助,不保证是最小 diff。
|
|
53
|
+
*/
|
|
54
|
+
interface VirtualChange {
|
|
55
|
+
/** 操作路径 */
|
|
56
|
+
readonly path: string;
|
|
57
|
+
/** 变更操作类型 */
|
|
58
|
+
readonly type: VirtualChangeType;
|
|
59
|
+
/** rename/copy 操作的源路径(其他操作为 undefined) */
|
|
60
|
+
readonly oldPath?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 创建 VirtualWorkdirSession 的选项
|
|
64
|
+
*/
|
|
65
|
+
interface CreateVirtualWorkdirSessionOptions {
|
|
66
|
+
/** 基线 tree 的 SHA-1 哈希 */
|
|
67
|
+
readonly baseTree: SHA1;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Virtual Workdir session 标识
|
|
71
|
+
*/
|
|
72
|
+
type VirtualWorkdirSessionId = string & {
|
|
73
|
+
readonly __brand: "VirtualWorkdirSessionId";
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* VirtualWorkdirSession(虚拟工作目录会话)
|
|
77
|
+
*
|
|
78
|
+
* 提供独立生命周期的可变 tree 视图,基于 `baseTree + CoW overlay` 模型。
|
|
79
|
+
* 不绑定 commit,不涉及 Git index / 真实工作目录。
|
|
80
|
+
*
|
|
81
|
+
* 当前 session 对 origin 仓库对象采用弱保证:
|
|
82
|
+
* 如果 base tree / origin blob 在后续被移除、损坏或不可读取,
|
|
83
|
+
* 相关读取、`revert()`、`writeTree()` 等操作会抛出 `VirtualOriginUnavailableError`。
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { createMemoryRepository } from "nano-git/repository/memory";
|
|
88
|
+
* import { createVirtualWorkdirSession } from "nano-git/workdir/memory";
|
|
89
|
+
*
|
|
90
|
+
* const repo = createMemoryRepository();
|
|
91
|
+
* const tree = repo.writeTree(); // 初始空 tree
|
|
92
|
+
* const session = createVirtualWorkdirSession(repo, { baseTree: tree });
|
|
93
|
+
*
|
|
94
|
+
* session.writeFile("hello.txt", Buffer.from("world"));
|
|
95
|
+
* const newTree = session.writeTree();
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
interface VirtualWorkdirSession {
|
|
99
|
+
/** 当前基线 tree 的 SHA-1 哈希 */
|
|
100
|
+
readonly baseTree: SHA1;
|
|
101
|
+
/** 路径是否存在 */
|
|
102
|
+
exists(path: string): boolean;
|
|
103
|
+
/** 获取路径状态信息,不存在时返回 null */
|
|
104
|
+
stat(path: string): VirtualEntryStat | null;
|
|
105
|
+
/** 读取目录内容,根目录为 "" */
|
|
106
|
+
readdir(path?: string): VirtualDirEntry[];
|
|
107
|
+
/** 读取文件内容 */
|
|
108
|
+
readFile(path: string): Buffer;
|
|
109
|
+
/** 读取符号链接目标 */
|
|
110
|
+
readLink(path: string): string;
|
|
111
|
+
/** 写入文件(新建或覆盖) */
|
|
112
|
+
writeFile(path: string, content: Buffer, options?: {
|
|
113
|
+
readonly mode?: "100644" | "100755";
|
|
114
|
+
}): void;
|
|
115
|
+
/** 写入符号链接(新建或覆盖) */
|
|
116
|
+
writeLink(path: string, target: string): void;
|
|
117
|
+
/** 创建目录(含必要父目录) */
|
|
118
|
+
mkdir(path: string): void;
|
|
119
|
+
/** 删除路径(文件、目录或符号链接) */
|
|
120
|
+
delete(path: string): void;
|
|
121
|
+
/**
|
|
122
|
+
* 重命名路径
|
|
123
|
+
*
|
|
124
|
+
* 只做路径重绑定,不退化为 delete + write。
|
|
125
|
+
* 目录重命名后,子项保持懒加载。
|
|
126
|
+
*/
|
|
127
|
+
rename(from: string, to: string): void;
|
|
128
|
+
/**
|
|
129
|
+
* 复制路径
|
|
130
|
+
*
|
|
131
|
+
* 新建 session node,共享 origin,不共享 node 身份。
|
|
132
|
+
* 目录复制为浅复制,子项保持懒加载。
|
|
133
|
+
*/
|
|
134
|
+
copy(from: string, to: string): void;
|
|
135
|
+
/**
|
|
136
|
+
* 恢复路径到其 origin
|
|
137
|
+
*
|
|
138
|
+
* 仅对当前 CoW 节点恢复其 origin(repo-backed 版本)。
|
|
139
|
+
* 对纯新建节点抛出 VirtualRevertNotSupportedError。
|
|
140
|
+
*/
|
|
141
|
+
revert(path: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* 导出当前 overlay 为新 tree
|
|
144
|
+
*
|
|
145
|
+
* 只重新合成受影响目录,复用未修改节点的哈希。
|
|
146
|
+
* 不自动推进 baseTree。
|
|
147
|
+
*/
|
|
148
|
+
writeTree(): SHA1;
|
|
149
|
+
/**
|
|
150
|
+
* 重置 session 到指定基线 tree
|
|
151
|
+
*
|
|
152
|
+
* 丢弃全部 overlay 与变更历史。
|
|
153
|
+
*/
|
|
154
|
+
reset(baseTree: SHA1): void;
|
|
155
|
+
/**
|
|
156
|
+
* 列出会话内的变更记录
|
|
157
|
+
*
|
|
158
|
+
* 是会话内调试/测试辅助,不保证是最小 diff 引擎。
|
|
159
|
+
* 输出稳定、测试可断言即可。
|
|
160
|
+
*/
|
|
161
|
+
listChanges(): VirtualChange[];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* VirtualWorkdirBackend
|
|
165
|
+
*
|
|
166
|
+
* session 内部状态存储的抽象接口。
|
|
167
|
+
* memory / file / sqlite 后端通过实现此接口来提供不同的持久化策略。
|
|
168
|
+
*
|
|
169
|
+
* `file` / `sqlite` 持久化 backend 当前都按单进程、单写者场景收口;
|
|
170
|
+
* 不承诺跨进程并发写安全,也不提供多写者协调协议。
|
|
171
|
+
*
|
|
172
|
+
* 本接口在后续 Phase 中会逐步补充完整方法签名。
|
|
173
|
+
* 当前仅为命名冻结与角色声明。
|
|
174
|
+
*/
|
|
175
|
+
interface VirtualWorkdirBackend {
|
|
176
|
+
/** 后端类型标识 */
|
|
177
|
+
readonly kind: "memory" | "file" | "sqlite";
|
|
178
|
+
/** 创建新 session 并返回其标识 */
|
|
179
|
+
createSession(options: CreateVirtualWorkdirSessionOptions): VirtualWorkdirSessionId;
|
|
180
|
+
/** 打开已存在的 session */
|
|
181
|
+
openSession(source: ObjectDatabase, sessionId: VirtualWorkdirSessionId): VirtualWorkdirSession;
|
|
182
|
+
/** 删除 session */
|
|
183
|
+
deleteSession(sessionId: VirtualWorkdirSessionId): void;
|
|
184
|
+
/** 列出当前后端中可完整恢复的 session */
|
|
185
|
+
listSessions(): VirtualWorkdirSessionId[];
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
export { CreateVirtualWorkdirSessionOptions, VirtualChange, VirtualChangeType, VirtualDirEntry, VirtualEntryKind, VirtualEntryStat, VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError, VirtualWorkdirBackend, VirtualWorkdirSession, VirtualWorkdirSessionId };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError } from "../core/errors.mjs";
|
|
2
|
+
export { VirtualNotDirectoryError, VirtualNotFileError, VirtualNotSymlinkError, VirtualOriginUnavailableError, VirtualPathAlreadyExistsError, VirtualPathNotFoundError, VirtualRevertNotSupportedError };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { VirtualWorkdirBackend } from "./core.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/workdir/file-backend.d.ts
|
|
4
|
+
/** 创建文件系统 Virtual Workdir backend 的可选参数 */
|
|
5
|
+
interface CreateFileVirtualWorkdirBackendOptions {
|
|
6
|
+
/** session 根目录名,默认 `sessions` */
|
|
7
|
+
readonly sessionsDirName?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 创建基于文件系统目录的 Virtual Workdir backend
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const backend = createFileVirtualWorkdirBackend("/tmp/workdirs");
|
|
15
|
+
* const sessionId = backend.createSession({ baseTree: tree });
|
|
16
|
+
* const session = backend.openSession(repo.objects, sessionId);
|
|
17
|
+
* expect(session.baseTree).toBe(tree);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
declare function createFileVirtualWorkdirBackend(rootDir: string, options?: CreateFileVirtualWorkdirBackendOptions): VirtualWorkdirBackend;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { CreateFileVirtualWorkdirBackendOptions, createFileVirtualWorkdirBackend };
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createRootDirectoryNode } from "./nodes.mjs";
|
|
2
|
+
import { createVirtualWorkdirSessionId } from "./session-id.mjs";
|
|
3
|
+
import { openVirtualWorkdirSession } from "./session.mjs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
//#region src/workdir/file-backend.ts
|
|
7
|
+
/**
|
|
8
|
+
* Virtual Workdir 文件系统 backend
|
|
9
|
+
*/
|
|
10
|
+
const FILE_WORKDIR_MANIFEST_VERSION = 1;
|
|
11
|
+
const FILE_WORKDIR_TRANSACTION_SNAPSHOT_SUFFIX = ".txn-snapshot";
|
|
12
|
+
/**
|
|
13
|
+
* 创建基于文件系统目录的 Virtual Workdir backend
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const backend = createFileVirtualWorkdirBackend("/tmp/workdirs");
|
|
18
|
+
* const sessionId = backend.createSession({ baseTree: tree });
|
|
19
|
+
* const session = backend.openSession(repo.objects, sessionId);
|
|
20
|
+
* expect(session.baseTree).toBe(tree);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
function createFileVirtualWorkdirBackend(rootDir, options = {}) {
|
|
24
|
+
const sessionsRoot = join(rootDir, options.sessionsDirName ?? "sessions");
|
|
25
|
+
mkdirSync(sessionsRoot, { recursive: true });
|
|
26
|
+
return {
|
|
27
|
+
kind: "file",
|
|
28
|
+
createSession(options) {
|
|
29
|
+
const sessionId = createVirtualWorkdirSessionId();
|
|
30
|
+
createFileVirtualWorkdirStateStore(sessionsRoot, sessionId).reset(options.baseTree);
|
|
31
|
+
return sessionId;
|
|
32
|
+
},
|
|
33
|
+
openSession(source, sessionId) {
|
|
34
|
+
if (!hasSession(sessionsRoot, sessionId)) throw new Error(`Virtual workdir session not found: ${sessionId}`);
|
|
35
|
+
validateSessionIntegrity(sessionsRoot, sessionId);
|
|
36
|
+
return openVirtualWorkdirSession(source, createFileVirtualWorkdirStateStore(sessionsRoot, sessionId));
|
|
37
|
+
},
|
|
38
|
+
deleteSession(sessionId) {
|
|
39
|
+
if (!hasSession(sessionsRoot, sessionId)) throw new Error(`Virtual workdir session not found: ${sessionId}`);
|
|
40
|
+
rmSync(getSessionDir(sessionsRoot, sessionId), {
|
|
41
|
+
recursive: true,
|
|
42
|
+
force: true
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
listSessions() {
|
|
46
|
+
if (!existsSync(sessionsRoot)) return [];
|
|
47
|
+
return readdirSync(sessionsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => !entry.name.endsWith(FILE_WORKDIR_TRANSACTION_SNAPSHOT_SUFFIX)).filter((entry) => existsSync(getManifestPath(join(sessionsRoot, entry.name)))).map((entry) => decodePathToken(entry.name)).filter((sessionId) => {
|
|
48
|
+
try {
|
|
49
|
+
validateSessionIntegrity(sessionsRoot, sessionId);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}).sort();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 创建单个 session 的文件系统状态存储
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const store = createFileVirtualWorkdirStateStore("/tmp/workdirs/sessions", sessionId);
|
|
64
|
+
* expect(store.kind).toBe("file");
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function createFileVirtualWorkdirStateStore(sessionsRoot, sessionId) {
|
|
68
|
+
const sessionDir = getSessionDir(sessionsRoot, sessionId);
|
|
69
|
+
const manifestPath = getManifestPath(sessionDir);
|
|
70
|
+
const contentDir = getContentDir(sessionDir);
|
|
71
|
+
return {
|
|
72
|
+
kind: "file",
|
|
73
|
+
transact(fn) {
|
|
74
|
+
const snapshotDir = `${sessionDir}${FILE_WORKDIR_TRANSACTION_SNAPSHOT_SUFFIX}`;
|
|
75
|
+
rmSync(snapshotDir, {
|
|
76
|
+
recursive: true,
|
|
77
|
+
force: true
|
|
78
|
+
});
|
|
79
|
+
if (existsSync(sessionDir)) copyDirectoryRecursive(sessionDir, snapshotDir);
|
|
80
|
+
try {
|
|
81
|
+
const result = fn();
|
|
82
|
+
rmSync(snapshotDir, {
|
|
83
|
+
recursive: true,
|
|
84
|
+
force: true
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
rmSync(sessionDir, {
|
|
89
|
+
recursive: true,
|
|
90
|
+
force: true
|
|
91
|
+
});
|
|
92
|
+
if (existsSync(snapshotDir)) renameSync(snapshotDir, sessionDir);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
readBaseTree() {
|
|
97
|
+
return readManifest(manifestPath).baseTree;
|
|
98
|
+
},
|
|
99
|
+
writeBaseTree(baseTree) {
|
|
100
|
+
updateManifest(sessionDir, (manifest) => ({
|
|
101
|
+
...manifest,
|
|
102
|
+
baseTree
|
|
103
|
+
}));
|
|
104
|
+
},
|
|
105
|
+
getNode(id) {
|
|
106
|
+
const record = readManifest(manifestPath).nodes[id];
|
|
107
|
+
if (record === void 0) return null;
|
|
108
|
+
return restoreNode(record, contentDir);
|
|
109
|
+
},
|
|
110
|
+
setNode(node) {
|
|
111
|
+
updateManifest(sessionDir, (manifest) => {
|
|
112
|
+
const record = persistNode(contentDir, node);
|
|
113
|
+
return {
|
|
114
|
+
...manifest,
|
|
115
|
+
nodes: {
|
|
116
|
+
...manifest.nodes,
|
|
117
|
+
[node.id]: record
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
deleteNode(id) {
|
|
123
|
+
updateManifest(sessionDir, (manifest) => {
|
|
124
|
+
if (manifest.nodes[id] === void 0) return manifest;
|
|
125
|
+
const { [id]: _deleted, ...rest } = manifest.nodes;
|
|
126
|
+
return {
|
|
127
|
+
...manifest,
|
|
128
|
+
nodes: rest
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
appendChange(record) {
|
|
133
|
+
updateManifest(sessionDir, (manifest) => ({
|
|
134
|
+
...manifest,
|
|
135
|
+
changes: [...manifest.changes, record]
|
|
136
|
+
}));
|
|
137
|
+
},
|
|
138
|
+
listChangeRecords() {
|
|
139
|
+
return readManifest(manifestPath).changes;
|
|
140
|
+
},
|
|
141
|
+
clearChanges() {
|
|
142
|
+
updateManifest(sessionDir, (manifest) => ({
|
|
143
|
+
...manifest,
|
|
144
|
+
changes: []
|
|
145
|
+
}));
|
|
146
|
+
},
|
|
147
|
+
reset(baseTree) {
|
|
148
|
+
rmSync(sessionDir, {
|
|
149
|
+
recursive: true,
|
|
150
|
+
force: true
|
|
151
|
+
});
|
|
152
|
+
ensureSessionDirs(sessionDir, contentDir);
|
|
153
|
+
writeManifestAtomic(manifestPath, createManifest(baseTree, { [createRootDirectoryNode(baseTree).id]: serializeDirectoryNode(createRootDirectoryNode(baseTree)) }));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function hasSession(sessionsRoot, sessionId) {
|
|
158
|
+
return existsSync(getManifestPath(getSessionDir(sessionsRoot, sessionId)));
|
|
159
|
+
}
|
|
160
|
+
function validateSessionIntegrity(sessionsRoot, sessionId) {
|
|
161
|
+
const sessionDir = getSessionDir(sessionsRoot, sessionId);
|
|
162
|
+
const manifest = readManifest(getManifestPath(sessionDir));
|
|
163
|
+
const root = manifest.nodes.root;
|
|
164
|
+
if (root === void 0) throw new Error(`Virtual workdir session is corrupted: missing root node for ${sessionId}`);
|
|
165
|
+
if (restoreNode(root, getContentDir(sessionDir)).state.kind !== "directory") throw new Error(`Virtual workdir session is corrupted: root node is not a directory for ${sessionId}`);
|
|
166
|
+
for (const record of Object.values(manifest.nodes)) restoreNode(record, getContentDir(sessionDir));
|
|
167
|
+
}
|
|
168
|
+
function getSessionDir(sessionsRoot, sessionId) {
|
|
169
|
+
return join(sessionsRoot, encodePathToken(sessionId));
|
|
170
|
+
}
|
|
171
|
+
function getManifestPath(sessionDir) {
|
|
172
|
+
return join(sessionDir, "manifest.json");
|
|
173
|
+
}
|
|
174
|
+
function getContentDir(sessionDir) {
|
|
175
|
+
return join(sessionDir, "content");
|
|
176
|
+
}
|
|
177
|
+
function getContentPath(contentDir, payloadRef) {
|
|
178
|
+
return join(contentDir, `${encodePathToken(payloadRef)}.bin`);
|
|
179
|
+
}
|
|
180
|
+
function ensureSessionDirs(sessionDir, contentDir) {
|
|
181
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
182
|
+
mkdirSync(contentDir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
function copyDirectoryRecursive(sourceDir, targetDir) {
|
|
185
|
+
mkdirSync(targetDir, { recursive: true });
|
|
186
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
187
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
188
|
+
const targetPath = join(targetDir, entry.name);
|
|
189
|
+
if (entry.isDirectory()) {
|
|
190
|
+
copyDirectoryRecursive(sourcePath, targetPath);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function createManifest(baseTree, nodes) {
|
|
197
|
+
return {
|
|
198
|
+
formatVersion: FILE_WORKDIR_MANIFEST_VERSION,
|
|
199
|
+
baseTree,
|
|
200
|
+
changes: [],
|
|
201
|
+
nodes
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function updateManifest(sessionDir, updater) {
|
|
205
|
+
const manifestPath = getManifestPath(sessionDir);
|
|
206
|
+
ensureSessionDirs(sessionDir, getContentDir(sessionDir));
|
|
207
|
+
writeManifestAtomic(manifestPath, updater(readManifest(manifestPath)));
|
|
208
|
+
}
|
|
209
|
+
function writeManifestAtomic(path, manifest) {
|
|
210
|
+
writeJsonAtomic(path, manifest);
|
|
211
|
+
}
|
|
212
|
+
function readManifest(manifestPath) {
|
|
213
|
+
if (!existsSync(manifestPath)) throw new Error(`Virtual workdir session manifest not found: ${manifestPath}`);
|
|
214
|
+
const manifest = readJson(manifestPath);
|
|
215
|
+
validateManifest(manifest);
|
|
216
|
+
return manifest;
|
|
217
|
+
}
|
|
218
|
+
function validateManifest(manifest) {
|
|
219
|
+
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
|
+
if (typeof manifest.nodes !== "object" || manifest.nodes === null || Array.isArray(manifest.nodes)) throw new Error("Invalid virtual workdir manifest nodes");
|
|
222
|
+
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
|
+
}
|
|
224
|
+
function persistNode(contentDir, node) {
|
|
225
|
+
if (node.state.kind === "directory") return serializeDirectoryNode(node);
|
|
226
|
+
if (node.state.kind === "file") {
|
|
227
|
+
const contentRef = node.state.content === void 0 ? null : persistPayload(contentDir, node.id, node.state.content);
|
|
228
|
+
return {
|
|
229
|
+
id: node.id,
|
|
230
|
+
origin: node.origin,
|
|
231
|
+
state: {
|
|
232
|
+
kind: "file",
|
|
233
|
+
mode: node.state.mode,
|
|
234
|
+
contentRef
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const targetRef = node.state.target === void 0 ? null : persistPayload(contentDir, node.id, node.state.target);
|
|
239
|
+
return {
|
|
240
|
+
id: node.id,
|
|
241
|
+
origin: node.origin,
|
|
242
|
+
state: {
|
|
243
|
+
kind: "symlink",
|
|
244
|
+
mode: "120000",
|
|
245
|
+
targetRef
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function serializeDirectoryNode(node) {
|
|
250
|
+
if (node.state.kind !== "directory") throw new Error("serializeDirectoryNode: node is not a directory");
|
|
251
|
+
return {
|
|
252
|
+
id: node.id,
|
|
253
|
+
origin: node.origin,
|
|
254
|
+
state: {
|
|
255
|
+
kind: "directory",
|
|
256
|
+
overlay: {
|
|
257
|
+
addedEntries: Array.from(node.state.overlay.addedEntries.entries()),
|
|
258
|
+
deletedNames: Array.from(node.state.overlay.deletedNames.values())
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function persistPayload(contentDir, nodeId, payload) {
|
|
264
|
+
mkdirSync(contentDir, { recursive: true });
|
|
265
|
+
const payloadRef = `${nodeId}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
|
|
266
|
+
writeBufferAtomic(getContentPath(contentDir, payloadRef), payload);
|
|
267
|
+
return payloadRef;
|
|
268
|
+
}
|
|
269
|
+
function restoreNode(record, contentDir) {
|
|
270
|
+
const origin = restoreOrigin(record.origin);
|
|
271
|
+
if (record.state.kind === "directory") return {
|
|
272
|
+
id: record.id,
|
|
273
|
+
origin,
|
|
274
|
+
state: {
|
|
275
|
+
kind: "directory",
|
|
276
|
+
overlay: readDirectoryOverlayRecord(record.state.overlay)
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
const rawState = record.state;
|
|
280
|
+
if (rawState.kind === "file") {
|
|
281
|
+
const mode = rawState.mode;
|
|
282
|
+
if (mode !== "100644" && mode !== "100755") throw new Error(`Invalid file workdir node state mode: ${String(mode)}`);
|
|
283
|
+
const contentRef = rawState.contentRef;
|
|
284
|
+
if (contentRef !== void 0 && contentRef !== null && typeof contentRef !== "string") throw new Error("Invalid file workdir node content ref");
|
|
285
|
+
return {
|
|
286
|
+
id: record.id,
|
|
287
|
+
origin,
|
|
288
|
+
state: {
|
|
289
|
+
kind: "file",
|
|
290
|
+
mode,
|
|
291
|
+
content: contentRef === void 0 || contentRef === null ? void 0 : readPayload(contentDir, contentRef)
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (rawState.kind !== "symlink") throw new Error(`Invalid file workdir node state kind: ${String(rawState.kind)}`);
|
|
296
|
+
const targetRef = rawState.targetRef;
|
|
297
|
+
if (targetRef !== void 0 && targetRef !== null && typeof targetRef !== "string") throw new Error("Invalid file workdir node target ref");
|
|
298
|
+
return {
|
|
299
|
+
id: record.id,
|
|
300
|
+
origin,
|
|
301
|
+
state: {
|
|
302
|
+
kind: "symlink",
|
|
303
|
+
mode: "120000",
|
|
304
|
+
target: targetRef === void 0 || targetRef === null ? void 0 : readPayload(contentDir, targetRef)
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function readDirectoryOverlayRecord(value) {
|
|
309
|
+
if (!isFileDirectoryOverlayPayload(value)) throw new Error("Invalid file workdir directory overlay payload");
|
|
310
|
+
return {
|
|
311
|
+
addedEntries: new Map(value.addedEntries.map(([name, nodeId]) => [name, nodeId])),
|
|
312
|
+
deletedNames: new Set(value.deletedNames)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function isFileDirectoryOverlayPayload(value) {
|
|
316
|
+
if (typeof value !== "object" || value === null) return false;
|
|
317
|
+
const maybe = value;
|
|
318
|
+
if (!Array.isArray(maybe.addedEntries) || !Array.isArray(maybe.deletedNames)) return false;
|
|
319
|
+
const hasValidAddedEntries = maybe.addedEntries.every((entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && typeof entry[1] === "string");
|
|
320
|
+
const hasValidDeletedNames = maybe.deletedNames.every((name) => typeof name === "string");
|
|
321
|
+
return hasValidAddedEntries && hasValidDeletedNames;
|
|
322
|
+
}
|
|
323
|
+
function restoreOrigin(record) {
|
|
324
|
+
const origin = record;
|
|
325
|
+
if (origin.kind === "none") return { kind: "none" };
|
|
326
|
+
if (origin.kind === "repo-tree") {
|
|
327
|
+
if (typeof origin.hash !== "string" || origin.hash.length === 0) throw new Error("Invalid file workdir node: repo-tree origin is missing hash");
|
|
328
|
+
return {
|
|
329
|
+
kind: "repo-tree",
|
|
330
|
+
hash: origin.hash
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (origin.kind !== "repo-blob") throw new Error(`Invalid file workdir node origin kind: ${String(origin.kind)}`);
|
|
334
|
+
if (typeof origin.hash !== "string" || origin.hash.length === 0) throw new Error("Invalid file workdir node: repo-blob origin is missing hash");
|
|
335
|
+
if (origin.mode !== "100644" && origin.mode !== "100755" && origin.mode !== "120000") throw new Error(`Invalid file workdir node origin mode: ${String(origin.mode)}`);
|
|
336
|
+
return {
|
|
337
|
+
kind: "repo-blob",
|
|
338
|
+
mode: origin.mode,
|
|
339
|
+
hash: origin.hash
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function readPayload(contentDir, payloadRef) {
|
|
343
|
+
const path = getContentPath(contentDir, payloadRef);
|
|
344
|
+
if (!existsSync(path)) throw new Error(`Virtual workdir payload not found: ${payloadRef}`);
|
|
345
|
+
return readFileSync(path);
|
|
346
|
+
}
|
|
347
|
+
function readJson(path) {
|
|
348
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
349
|
+
}
|
|
350
|
+
function writeJsonAtomic(path, value) {
|
|
351
|
+
writeBufferAtomic(path, Buffer.from(JSON.stringify(value), "utf-8"));
|
|
352
|
+
}
|
|
353
|
+
function writeBufferAtomic(path, value) {
|
|
354
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
355
|
+
const tempPath = `${path}.tmp`;
|
|
356
|
+
writeFileSync(tempPath, value);
|
|
357
|
+
renameSync(tempPath, path);
|
|
358
|
+
}
|
|
359
|
+
function encodePathToken(value) {
|
|
360
|
+
return encodeURIComponent(value);
|
|
361
|
+
}
|
|
362
|
+
function decodePathToken(value) {
|
|
363
|
+
return decodeURIComponent(value);
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
export { createFileVirtualWorkdirBackend };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/workdir/ids.ts
|
|
2
|
+
/** 根目录节点的固定 ID(每个 session 一致) */
|
|
3
|
+
const VIRTUAL_ROOT_NODE_ID = "root";
|
|
4
|
+
let nextNodeCounter = 1;
|
|
5
|
+
/**
|
|
6
|
+
* 分配新的 session 节点 ID
|
|
7
|
+
*/
|
|
8
|
+
function createNodeId() {
|
|
9
|
+
const id = `node:${nextNodeCounter}`;
|
|
10
|
+
nextNodeCounter += 1;
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 为仓库对象哈希派生稳定的 session 节点 ID(懒加载、未 copy 前复用)
|
|
15
|
+
*/
|
|
16
|
+
function originBackedNodeId(hash) {
|
|
17
|
+
return `origin:${hash}`;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { VIRTUAL_ROOT_NODE_ID, createNodeId, originBackedNodeId };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { VirtualWorkdirBackend } from "./core.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/workdir/memory-backend.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* 创建内存版 Virtual Workdir backend
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const backend = createMemoryVirtualWorkdirBackend();
|
|
10
|
+
* const sessionId = backend.createSession({ baseTree: tree });
|
|
11
|
+
* const session = backend.openSession(repo.objects, sessionId);
|
|
12
|
+
* expect(session.baseTree).toBe(tree);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
declare function createMemoryVirtualWorkdirBackend(): VirtualWorkdirBackend;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { createMemoryVirtualWorkdirBackend };
|