@davideasden/pi-undo 0.1.0
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/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { checksum, topologyFingerprint } from "./encoding.ts";
|
|
5
|
+
import { GitRunner } from "./git-runner.ts";
|
|
6
|
+
import type { DiscoveryRoot } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
interface RepositoryInfo {
|
|
9
|
+
readonly absoluteRoot: string;
|
|
10
|
+
readonly commonGitDir: string;
|
|
11
|
+
readonly sourceIdentity: string;
|
|
12
|
+
readonly treeId: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RepositoryInspection =
|
|
16
|
+
| { readonly kind: "active"; readonly repository: RepositoryInfo }
|
|
17
|
+
| { readonly kind: "broken"; readonly absoluteRoot: string }
|
|
18
|
+
| { readonly kind: "absent" };
|
|
19
|
+
|
|
20
|
+
interface DiscoveredRoot {
|
|
21
|
+
readonly absoluteRoot: string;
|
|
22
|
+
readonly relativeRoot: string;
|
|
23
|
+
readonly gitBacked: boolean;
|
|
24
|
+
readonly state: DiscoveryRoot["state"];
|
|
25
|
+
readonly sourceIdentity: string;
|
|
26
|
+
readonly privateRepositoryId: string;
|
|
27
|
+
readonly treeId: string | null;
|
|
28
|
+
readonly gitlinkOid?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RootTopology {
|
|
32
|
+
readonly workspaceIdentity: string;
|
|
33
|
+
readonly roots: readonly DiscoveryRoot[];
|
|
34
|
+
readonly fingerprint: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
|
|
38
|
+
|
|
39
|
+
export class RootDiscoveryError extends Error {
|
|
40
|
+
readonly code: RootDiscoveryErrorCode;
|
|
41
|
+
|
|
42
|
+
constructor(code: RootDiscoveryErrorCode, message: string) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "RootDiscoveryError";
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RootDiscovery {
|
|
50
|
+
discover(workspaceRoot: string): Promise<RootTopology>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class RootDiscovery {
|
|
54
|
+
private readonly git: GitRunner;
|
|
55
|
+
|
|
56
|
+
constructor(git = new GitRunner()) {
|
|
57
|
+
this.git = git;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async discover(workspaceRoot: string): Promise<RootTopology> {
|
|
61
|
+
const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot);
|
|
62
|
+
const activeRoots = new Map<string, DiscoveredRoot>();
|
|
63
|
+
const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity);
|
|
64
|
+
if (outerRepository.kind === "active") {
|
|
65
|
+
activeRoots.set(
|
|
66
|
+
outerRepository.repository.absoluteRoot,
|
|
67
|
+
this.activeRoot(workspaceIdentity, outerRepository.repository),
|
|
68
|
+
);
|
|
69
|
+
} else if (outerRepository.kind === "broken") {
|
|
70
|
+
activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot));
|
|
71
|
+
} else {
|
|
72
|
+
activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots);
|
|
76
|
+
const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots);
|
|
77
|
+
const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]);
|
|
78
|
+
return {
|
|
79
|
+
workspaceIdentity,
|
|
80
|
+
roots,
|
|
81
|
+
fingerprint: topologyFingerprint(workspaceIdentity, roots),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async scanDirectory(
|
|
86
|
+
workspaceIdentity: string,
|
|
87
|
+
directory: string,
|
|
88
|
+
activeRoots: Map<string, DiscoveredRoot>,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (!await isSafeDirectory(directory, workspaceIdentity)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
94
|
+
if (!await isSafeDirectory(directory, workspaceIdentity)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name === ".git" || entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const candidate = join(directory, entry.name);
|
|
102
|
+
if (!await isSafeDirectory(candidate, workspaceIdentity)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const inspection = await this.inspectRepository(candidate, workspaceIdentity);
|
|
106
|
+
if (inspection.kind === "active") {
|
|
107
|
+
activeRoots.set(
|
|
108
|
+
inspection.repository.absoluteRoot,
|
|
109
|
+
this.activeRoot(workspaceIdentity, inspection.repository),
|
|
110
|
+
);
|
|
111
|
+
} else if (inspection.kind === "broken") {
|
|
112
|
+
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
113
|
+
}
|
|
114
|
+
await this.scanDirectory(workspaceIdentity, candidate, activeRoots);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {
|
|
119
|
+
const marker = await gitMarkerState(candidate);
|
|
120
|
+
if (marker === "absent") {
|
|
121
|
+
return { kind: "absent" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let absoluteRoot: string;
|
|
125
|
+
try {
|
|
126
|
+
absoluteRoot = await realpath(candidate);
|
|
127
|
+
} catch {
|
|
128
|
+
return { kind: "broken", absoluteRoot: resolve(candidate) };
|
|
129
|
+
}
|
|
130
|
+
if (!isWithin(workspaceIdentity, absoluteRoot)) {
|
|
131
|
+
return { kind: "absent" };
|
|
132
|
+
}
|
|
133
|
+
if (marker === "invalid") {
|
|
134
|
+
return { kind: "broken", absoluteRoot };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const details = await this.gitOutput([
|
|
138
|
+
"-C",
|
|
139
|
+
absoluteRoot,
|
|
140
|
+
"rev-parse",
|
|
141
|
+
"--show-toplevel",
|
|
142
|
+
"--git-dir",
|
|
143
|
+
"--git-common-dir",
|
|
144
|
+
]);
|
|
145
|
+
if (details === null) {
|
|
146
|
+
return { kind: "broken", absoluteRoot };
|
|
147
|
+
}
|
|
148
|
+
const lines = details.trimEnd().split("\n");
|
|
149
|
+
if (lines.length < 3) {
|
|
150
|
+
return { kind: "broken", absoluteRoot };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
if ((await realpath(lines[0])) !== absoluteRoot) {
|
|
154
|
+
return { kind: "broken", absoluteRoot };
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return { kind: "broken", absoluteRoot };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const commonGitDir = resolve(absoluteRoot, lines[2]);
|
|
161
|
+
const remote = await this.gitOutput(["-C", absoluteRoot, "config", "--get", "remote.origin.url"]);
|
|
162
|
+
const head = await this.gitOutput(["-C", absoluteRoot, "rev-parse", "HEAD"]);
|
|
163
|
+
return {
|
|
164
|
+
kind: "active",
|
|
165
|
+
repository: {
|
|
166
|
+
absoluteRoot,
|
|
167
|
+
commonGitDir,
|
|
168
|
+
sourceIdentity: remote?.trim() || `git:${commonGitDir}`,
|
|
169
|
+
treeId: head?.trim() || null,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private activeRoot(workspaceIdentity: string, repository: RepositoryInfo): DiscoveredRoot {
|
|
175
|
+
return {
|
|
176
|
+
absoluteRoot: repository.absoluteRoot,
|
|
177
|
+
relativeRoot: workspaceRelativePath(workspaceIdentity, repository.absoluteRoot),
|
|
178
|
+
gitBacked: true,
|
|
179
|
+
state: "active",
|
|
180
|
+
sourceIdentity: repository.sourceIdentity,
|
|
181
|
+
privateRepositoryId: checksum(repository.sourceIdentity),
|
|
182
|
+
treeId: repository.treeId,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async discoverGitlinks(
|
|
187
|
+
workspaceIdentity: string,
|
|
188
|
+
activeRoots: Map<string, DiscoveredRoot>,
|
|
189
|
+
): Promise<Map<string, DiscoveredRoot>> {
|
|
190
|
+
const result = new Map<string, DiscoveredRoot>();
|
|
191
|
+
for (const root of activeRoots.values()) {
|
|
192
|
+
if (!root.gitBacked || root.state !== "active") {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const stage = await this.gitOutput(["-C", root.absoluteRoot, "ls-files", "--stage", "-z"]);
|
|
196
|
+
if (stage === null) {
|
|
197
|
+
throw new RootDiscoveryError("discovery_failed", "无法读取 Git index");
|
|
198
|
+
}
|
|
199
|
+
for (const gitlink of parseGitlinks(stage)) {
|
|
200
|
+
const absolutePath = resolve(root.absoluteRoot, gitlink.relativePath);
|
|
201
|
+
if (!isWithin(workspaceIdentity, absolutePath)) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const relativeRoot = workspaceRelativePath(workspaceIdentity, absolutePath);
|
|
205
|
+
const existing = [...activeRoots.entries()].find(([, candidate]) => candidate.relativeRoot === relativeRoot);
|
|
206
|
+
if (existing !== undefined) {
|
|
207
|
+
activeRoots.set(existing[0], { ...existing[1], gitlinkOid: gitlink.oid });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const state = await gitlinkState(absolutePath);
|
|
211
|
+
const sourceIdentity = `${root.sourceIdentity}:${relativeRoot}`;
|
|
212
|
+
result.set(relativeRoot, {
|
|
213
|
+
absoluteRoot: absolutePath,
|
|
214
|
+
relativeRoot,
|
|
215
|
+
gitBacked: true,
|
|
216
|
+
state,
|
|
217
|
+
sourceIdentity,
|
|
218
|
+
privateRepositoryId: checksum(sourceIdentity),
|
|
219
|
+
treeId: null,
|
|
220
|
+
gitlinkOid: gitlink.oid,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async gitOutput(args: readonly string[]): Promise<string | null> {
|
|
228
|
+
try {
|
|
229
|
+
const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], {
|
|
230
|
+
env: cleanGitEnvironment(),
|
|
231
|
+
});
|
|
232
|
+
return result.killed ? null : result.stdout;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function syntheticRoot(workspaceIdentity: string): DiscoveredRoot {
|
|
240
|
+
return {
|
|
241
|
+
absoluteRoot: workspaceIdentity,
|
|
242
|
+
relativeRoot: ".",
|
|
243
|
+
gitBacked: false,
|
|
244
|
+
state: "active",
|
|
245
|
+
sourceIdentity: workspaceIdentity,
|
|
246
|
+
privateRepositoryId: checksum(workspaceIdentity),
|
|
247
|
+
treeId: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function brokenRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot {
|
|
252
|
+
const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot);
|
|
253
|
+
const sourceIdentity = `broken:${absoluteRoot}`;
|
|
254
|
+
return {
|
|
255
|
+
absoluteRoot,
|
|
256
|
+
relativeRoot,
|
|
257
|
+
gitBacked: false,
|
|
258
|
+
state: "broken",
|
|
259
|
+
sourceIdentity,
|
|
260
|
+
privateRepositoryId: checksum(sourceIdentity),
|
|
261
|
+
treeId: null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] {
|
|
266
|
+
const unique = new Map<string, DiscoveredRoot>();
|
|
267
|
+
for (const root of discovered) {
|
|
268
|
+
unique.set(root.relativeRoot, root);
|
|
269
|
+
}
|
|
270
|
+
const paths = [...unique.keys()].sort(comparePaths);
|
|
271
|
+
return paths.map((relativeRoot) => {
|
|
272
|
+
const root = unique.get(relativeRoot) as DiscoveredRoot;
|
|
273
|
+
const parentRoot = paths
|
|
274
|
+
.filter((candidate) => isStrictAncestor(candidate, relativeRoot))
|
|
275
|
+
.sort((left, right) => right.length - left.length || comparePaths(left, right))[0] ?? null;
|
|
276
|
+
return {
|
|
277
|
+
relativeRoot,
|
|
278
|
+
parentRoot,
|
|
279
|
+
state: root.state,
|
|
280
|
+
sourceIdentity: root.sourceIdentity,
|
|
281
|
+
privateRepositoryId: root.privateRepositoryId,
|
|
282
|
+
treeId: root.treeId,
|
|
283
|
+
gitBacked: root.gitBacked,
|
|
284
|
+
...(root.gitlinkOid === undefined ? {} : { gitlinkOid: root.gitlinkOid }),
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
|
|
290
|
+
try {
|
|
291
|
+
return await realpath(workspaceRoot);
|
|
292
|
+
} catch {
|
|
293
|
+
throw new RootDiscoveryError("workspace_not_found", "workspace 根目录不存在");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function gitMarkerState(candidate: string): Promise<"absent" | "safe" | "invalid"> {
|
|
298
|
+
try {
|
|
299
|
+
const marker = await lstat(join(candidate, ".git"));
|
|
300
|
+
if (marker.isSymbolicLink()) {
|
|
301
|
+
return "invalid";
|
|
302
|
+
}
|
|
303
|
+
return marker.isDirectory() || marker.isFile() ? "safe" : "invalid";
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return hasErrorCode(error, "ENOENT") ? "absent" : "invalid";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function isSafeDirectory(directory: string, workspaceIdentity: string): Promise<boolean> {
|
|
310
|
+
try {
|
|
311
|
+
const metadata = await lstat(directory);
|
|
312
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
return isWithin(workspaceIdentity, await realpath(directory));
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"]> {
|
|
322
|
+
try {
|
|
323
|
+
await lstat(absolutePath);
|
|
324
|
+
return "broken";
|
|
325
|
+
} catch {
|
|
326
|
+
return "uninitialized";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function cleanGitEnvironment(): Readonly<Record<string, string | undefined>> {
|
|
331
|
+
return {
|
|
332
|
+
GIT_DIR: undefined,
|
|
333
|
+
GIT_WORK_TREE: undefined,
|
|
334
|
+
GIT_INDEX_FILE: undefined,
|
|
335
|
+
GIT_COMMON_DIR: undefined,
|
|
336
|
+
GIT_OBJECT_DIRECTORY: undefined,
|
|
337
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
338
|
+
GIT_NAMESPACE: undefined,
|
|
339
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
340
|
+
GIT_CONFIG_COUNT: undefined,
|
|
341
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
342
|
+
GIT_CONFIG_SYSTEM: undefined,
|
|
343
|
+
GIT_CONFIG_GLOBAL: undefined,
|
|
344
|
+
GIT_CONFIG_NOSYSTEM: undefined,
|
|
345
|
+
GIT_ATTR_NOSYSTEM: undefined,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
350
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parseGitlinks(stage: string): Array<{ oid: string; relativePath: string }> {
|
|
354
|
+
const gitlinks: Array<{ oid: string; relativePath: string }> = [];
|
|
355
|
+
for (const entry of stage.split("\0")) {
|
|
356
|
+
if (entry.length === 0) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const separator = entry.indexOf("\t");
|
|
360
|
+
if (separator < 0) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const metadata = entry.slice(0, separator).split(" ");
|
|
364
|
+
if (metadata[0] !== "160000" || metadata.length < 2) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
gitlinks.push({ oid: metadata[1], relativePath: entry.slice(separator + 1) });
|
|
368
|
+
}
|
|
369
|
+
return gitlinks;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function workspaceRelativePath(workspaceIdentity: string, absolutePath: string): string {
|
|
373
|
+
const value = relative(workspaceIdentity, absolutePath);
|
|
374
|
+
return value.length === 0 ? "." : value.split(sep).join("/");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isWithin(parent: string, candidate: string): boolean {
|
|
378
|
+
const value = relative(parent, candidate);
|
|
379
|
+
return value.length === 0 || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isStrictAncestor(parent: string, child: string): boolean {
|
|
383
|
+
return parent === "." ? child !== "." : child.startsWith(`${parent}/`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function comparePaths(left: string, right: string): number {
|
|
387
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
388
|
+
}
|