@davideasden/pi-undo 0.2.18 → 0.2.20
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/extensions/pi-undo.ts +0 -2
- package/package.json +1 -1
- package/src/journal.ts +45 -0
- package/src/pi-runtime.ts +1 -0
- package/src/recovery.ts +48 -1
- package/src/restore-engine.ts +365 -44
- package/src/root-discovery.ts +49 -2
package/extensions/pi-undo.ts
CHANGED
|
@@ -69,8 +69,6 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
|
|
|
69
69
|
next.recovery,
|
|
70
70
|
);
|
|
71
71
|
} else next.reporter.setReady(history.undoCount, history.redoCount);
|
|
72
|
-
// 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
|
|
73
|
-
next.controller.warmUp();
|
|
74
72
|
} catch (error) {
|
|
75
73
|
if (currentGeneration !== generation) return;
|
|
76
74
|
runtime = undefined;
|
package/package.json
CHANGED
package/src/journal.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { appendFile, lstat, readFile, readdir, rm } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { fsyncDirectory, fsyncFile, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
|
|
5
|
+
import { hasDurablePack } from "./durable-pack.ts";
|
|
5
6
|
import {
|
|
6
7
|
assertCursor,
|
|
7
8
|
assertJournalState,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
CursorState,
|
|
14
15
|
JournalPhase,
|
|
15
16
|
JournalState,
|
|
17
|
+
MutationRecord,
|
|
16
18
|
OperationDescriptor,
|
|
17
19
|
} from "./model.ts";
|
|
18
20
|
import { MutationJournal } from "./mutation-journal.ts";
|
|
@@ -154,6 +156,31 @@ export class JournalStore {
|
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* 判断 transaction 的 mutation 义务是否已全部履行且净效果回到操作前状态:
|
|
161
|
+
* 所有记录都达到 CLEANED,且按路径折叠的 fingerprint 链首尾相接(前向变更
|
|
162
|
+
* 被补偿变更精确抵消);没有任何 mutation 记录且无 durable pack 时视为从未发生。
|
|
163
|
+
* 这是纯只读检查;评估期间 journal 发生变化或任何证据不确定时都返回 false。
|
|
164
|
+
*/
|
|
165
|
+
async isFullyCompensated(pending: PendingJournal): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
const current = await this.load(pending.descriptor.opId);
|
|
168
|
+
if (!samePendingJournal(current, pending)) return false;
|
|
169
|
+
const mutationJournal = this.mutationJournal(pending.descriptor.opId);
|
|
170
|
+
const records = await mutationJournal.load();
|
|
171
|
+
const compensated = records.length === 0
|
|
172
|
+
? !await hasDurablePack(mutationJournal)
|
|
173
|
+
: fingerprintChainsFoldToIdentity(records);
|
|
174
|
+
if (!compensated) return false;
|
|
175
|
+
const verified = await this.load(pending.descriptor.opId);
|
|
176
|
+
if (!samePendingJournal(verified, current)) return false;
|
|
177
|
+
const reloaded = await mutationJournal.load();
|
|
178
|
+
return reloaded.length === records.length && fingerprintChainsFoldToIdentity(reloaded);
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
|
|
158
185
|
if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
|
|
159
186
|
const pending = await this.load(opId);
|
|
@@ -414,6 +441,24 @@ function samePendingJournal(left: PendingJournal, right: PendingJournal): boolea
|
|
|
414
441
|
}
|
|
415
442
|
}
|
|
416
443
|
|
|
444
|
+
function fingerprintChainsFoldToIdentity(records: readonly MutationRecord[]): boolean {
|
|
445
|
+
const chains = new Map<string, { source: string; target: string }>();
|
|
446
|
+
for (const record of records) {
|
|
447
|
+
if (record.state !== "CLEANED") return false;
|
|
448
|
+
const chain = chains.get(record.path);
|
|
449
|
+
if (chain === undefined) {
|
|
450
|
+
chains.set(record.path, { source: record.sourceFingerprint, target: record.targetFingerprint });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (record.sourceFingerprint !== chain.target) return false;
|
|
454
|
+
chain.target = record.targetFingerprint;
|
|
455
|
+
}
|
|
456
|
+
for (const chain of chains.values()) {
|
|
457
|
+
if (chain.target !== chain.source) return false;
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
417
462
|
function isEmptyArray(value: unknown): value is readonly unknown[] {
|
|
418
463
|
return Array.isArray(value) && value.length === 0;
|
|
419
464
|
}
|
package/src/pi-runtime.ts
CHANGED
|
@@ -65,6 +65,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
|
|
|
65
65
|
getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
|
|
66
66
|
loadPending: () => journal.loadPending(),
|
|
67
67
|
assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
|
|
68
|
+
assessCompensatedTransaction: (pending) => journal.isFullyCompensated(pending),
|
|
68
69
|
inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
|
|
69
70
|
finalizeCursor: (pending, inspection) => finalizeCursorMarker(
|
|
70
71
|
pending.descriptor.sessionIdentity.path,
|
package/src/recovery.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface JournalRecoveryDependencies {
|
|
|
11
11
|
readonly loadPending: () => Promise<readonly PendingJournal[]>;
|
|
12
12
|
/** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
|
|
13
13
|
readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
|
|
14
|
+
/** 判断 transaction 是否已被完全补偿(mutation 全部 CLEANED 且净效果回到操作前状态),可直接 settle。 */
|
|
15
|
+
readonly assessCompensatedTransaction?: (journal: PendingJournal) => Promise<boolean>;
|
|
14
16
|
readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
|
|
15
17
|
readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
|
|
16
18
|
readonly recoverMutations: (
|
|
@@ -67,9 +69,22 @@ export class JournalRecovery {
|
|
|
67
69
|
for (const journal of pending) {
|
|
68
70
|
const identityError = this.identityError(journal);
|
|
69
71
|
if (identityError !== null) {
|
|
70
|
-
if (identityError === "session_identity_mismatch"
|
|
72
|
+
if (identityError === "session_identity_mismatch") {
|
|
73
|
+
if (await this.settleCompensated(journal)) {
|
|
74
|
+
recovered += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (await this.canIgnoreForeignTransaction(journal)) continue;
|
|
78
|
+
}
|
|
71
79
|
return { kind: "locked", reason: identityError, operations: recovered };
|
|
72
80
|
}
|
|
81
|
+
// 同一会话(重启或第二窗口)内,完全补偿的事务同样可直接 settle:
|
|
82
|
+
// 工作区义务已全部履行且无 cursor 提交证据,session leaf 位置对
|
|
83
|
+
// ABORTED 终结不构成安全输入,无需通过 leaf 校验。
|
|
84
|
+
if (await this.settleCompensated(journal)) {
|
|
85
|
+
recovered += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
73
88
|
let inspection: CursorMarkerInspection;
|
|
74
89
|
try {
|
|
75
90
|
inspection = await this.dependencies.inspectCursor(journal);
|
|
@@ -145,6 +160,38 @@ export class JournalRecovery {
|
|
|
145
160
|
}
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
/**
|
|
164
|
+
* 终结完全补偿的 pending transaction(own session 重启/第二窗口与 foreign 会话均适用)。
|
|
165
|
+
* 调用方持有 workspace lock,pending 非终态事务的 owner 操作已确定性结束;
|
|
166
|
+
* mutation 义务全部履行且净效果回到操作前状态时,无需触碰工作区即可 settle。
|
|
167
|
+
* cursor marker 必须缺失——marker 存在意味着操作已提交,与补偿证据矛盾,fail closed。
|
|
168
|
+
*/
|
|
169
|
+
private async settleCompensated(journal: PendingJournal): Promise<boolean> {
|
|
170
|
+
const assess = this.dependencies.assessCompensatedTransaction;
|
|
171
|
+
if (assess === undefined) return false;
|
|
172
|
+
let compensated: boolean;
|
|
173
|
+
try {
|
|
174
|
+
compensated = await assess(journal);
|
|
175
|
+
} catch {
|
|
176
|
+
// 评估失败等同于证据不足,退回原有恢复语义。
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (!compensated) return false;
|
|
180
|
+
let inspection: CursorMarkerInspection;
|
|
181
|
+
try {
|
|
182
|
+
inspection = await this.dependencies.inspectCursor(journal);
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (inspection.kind !== "absent") return false;
|
|
187
|
+
try {
|
|
188
|
+
await this.dependencies.settle(journal.descriptor.opId, "ABORTED");
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
148
195
|
private identityError(journal: PendingJournal): string | null {
|
|
149
196
|
if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
|
|
150
197
|
return "workspace_identity_mismatch";
|
package/src/restore-engine.ts
CHANGED
|
@@ -4,8 +4,11 @@ import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import { writeJsonAtomic } from "./atomic-fs.ts";
|
|
7
8
|
import {
|
|
8
9
|
createDurablePack,
|
|
10
|
+
hasDurablePack,
|
|
11
|
+
loadDurablePack,
|
|
9
12
|
publishCachedDurablePack,
|
|
10
13
|
removeDurablePack,
|
|
11
14
|
type DurableLeafInput,
|
|
@@ -33,8 +36,11 @@ import {
|
|
|
33
36
|
type ReplaceFileRequest,
|
|
34
37
|
} from "./quarantine.ts";
|
|
35
38
|
import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
|
|
39
|
+
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
36
40
|
|
|
37
41
|
const PREPARED_PLAN_CACHE_LIMIT = 16;
|
|
42
|
+
const DURABLE_CACHE_INDEX_FILE = "index.json";
|
|
43
|
+
const DURABLE_PACK_MEMORY_MAX_BYTES = 64 * 1024 * 1024;
|
|
38
44
|
const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
|
|
39
45
|
const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
|
|
40
46
|
const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
|
|
@@ -74,6 +80,8 @@ export interface RestoreEngineOptions {
|
|
|
74
80
|
readonly store: SnapshotStore;
|
|
75
81
|
readonly discovery?: RootDiscovery;
|
|
76
82
|
readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
|
|
83
|
+
/** 仅用于测试:覆盖进程内已加载 durable pack 的内存预算。 */
|
|
84
|
+
readonly durablePackCacheMaxBytes?: number;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export interface RestoreMutation {
|
|
@@ -95,6 +103,40 @@ interface PreparedRestorePlan {
|
|
|
95
103
|
readonly targetPaths: ReadonlyMap<string, OwnedPath>;
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
interface DurableCacheIndexEntry {
|
|
107
|
+
readonly currentManifestId: ManifestId;
|
|
108
|
+
readonly targetManifestId: ManifestId;
|
|
109
|
+
readonly scopePaths: readonly string[];
|
|
110
|
+
readonly planDigest: string;
|
|
111
|
+
readonly packChecksum: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface DurableCacheIndex {
|
|
115
|
+
readonly schemaVersion: 1;
|
|
116
|
+
entries: DurableCacheIndexEntry[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface CachedDurablePack {
|
|
120
|
+
readonly currentManifestId: ManifestId;
|
|
121
|
+
readonly targetManifestId: ManifestId;
|
|
122
|
+
readonly path: string;
|
|
123
|
+
readonly packChecksum: string;
|
|
124
|
+
readonly pinReason: string;
|
|
125
|
+
readonly pinsDeferred: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface CachedDurablePair {
|
|
129
|
+
readonly planDigest: string;
|
|
130
|
+
readonly pack: DurablePack;
|
|
131
|
+
readonly bytes: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type DurableIndexDisk =
|
|
135
|
+
| { readonly kind: "ok"; readonly index: DurableCacheIndex }
|
|
136
|
+
| { readonly kind: "missing" }
|
|
137
|
+
| { readonly kind: "corrupt" }
|
|
138
|
+
| { readonly kind: "unavailable" };
|
|
139
|
+
|
|
98
140
|
interface MutationContext {
|
|
99
141
|
readonly phase: RestoreMutation["phase"];
|
|
100
142
|
readonly sourceManifestId: ManifestId;
|
|
@@ -115,17 +157,17 @@ export class RestoreEngine {
|
|
|
115
157
|
private readonly discovery: RootDiscovery;
|
|
116
158
|
private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
|
|
117
159
|
private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
|
|
118
|
-
private readonly durablePackCache = new Map<string,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
private readonly
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
160
|
+
private readonly durablePackCache = new Map<string, CachedDurablePack>();
|
|
161
|
+
private readonly durablePackByPair = new Map<string, CachedDurablePair>();
|
|
162
|
+
private readonly durableIndexLock = new WorkspaceLock({
|
|
163
|
+
leaseMs: 10_000,
|
|
164
|
+
retryMs: 25,
|
|
165
|
+
acquireTimeoutMs: 1_000,
|
|
166
|
+
});
|
|
167
|
+
private readonly durablePackCacheMaxBytes: number;
|
|
168
|
+
private durableIndex: DurableCacheIndex | undefined;
|
|
169
|
+
private durableIndexQueue: Promise<void> = Promise.resolve();
|
|
170
|
+
private durablePackMemoryBytes = 0;
|
|
129
171
|
|
|
130
172
|
constructor(options: RestoreEngineOptions) {
|
|
131
173
|
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
@@ -133,6 +175,10 @@ export class RestoreEngine {
|
|
|
133
175
|
this.store = options.store;
|
|
134
176
|
this.discovery = options.discovery ?? new RootDiscovery();
|
|
135
177
|
this.beforeMutation = options.beforeMutation;
|
|
178
|
+
this.durablePackCacheMaxBytes = options.durablePackCacheMaxBytes ?? DURABLE_PACK_MEMORY_MAX_BYTES;
|
|
179
|
+
if (!Number.isSafeInteger(this.durablePackCacheMaxBytes) || this.durablePackCacheMaxBytes <= 0) {
|
|
180
|
+
throw new Error("durable pack 内存预算必须是正整数");
|
|
181
|
+
}
|
|
136
182
|
}
|
|
137
183
|
|
|
138
184
|
private async compatibleApplyOptions(): Promise<{
|
|
@@ -226,14 +272,27 @@ export class RestoreEngine {
|
|
|
226
272
|
target: SnapshotManifest,
|
|
227
273
|
scopePaths: readonly string[],
|
|
228
274
|
): Promise<boolean> {
|
|
229
|
-
const
|
|
230
|
-
|
|
275
|
+
const pairKey = durablePairKey(current.manifestId, target.manifestId, scopePaths);
|
|
276
|
+
let cached = this.durablePackByPair.get(pairKey);
|
|
277
|
+
if (cached !== undefined && !this.durablePackCache.has(cached.planDigest)) {
|
|
278
|
+
this.dropResidentDurablePack(pairKey);
|
|
279
|
+
cached = undefined;
|
|
280
|
+
}
|
|
281
|
+
const fromPersistentIndex = cached === undefined;
|
|
282
|
+
if (cached === undefined) {
|
|
283
|
+
cached = await this.readIndexedDurablePack(current.manifestId, target.manifestId, scopePaths);
|
|
284
|
+
}
|
|
285
|
+
if (cached === undefined || cached.pack.opId !== `cache-${cached.planDigest}`) return false;
|
|
231
286
|
const cacheJournal = new MutationJournal(
|
|
232
287
|
join(dirname(cached.pack.storagePath), "mutations.jsonl"),
|
|
233
288
|
`cache-${cached.planDigest}`,
|
|
234
289
|
);
|
|
235
290
|
try {
|
|
236
291
|
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
|
|
292
|
+
if (fromPersistentIndex) {
|
|
293
|
+
const expected = await this.plan(current, target, scopePaths);
|
|
294
|
+
if (expected.planDigest !== cached.planDigest) return false;
|
|
295
|
+
}
|
|
237
296
|
const topology = await this.discovery.discover(this.workspaceRoot);
|
|
238
297
|
this.assertCurrentTopology(current, target, topology);
|
|
239
298
|
const native = await createNativeFileBatch({
|
|
@@ -241,7 +300,19 @@ export class RestoreEngine {
|
|
|
241
300
|
planDigest: cached.planDigest,
|
|
242
301
|
journal: cacheJournal,
|
|
243
302
|
});
|
|
244
|
-
|
|
303
|
+
if (native === undefined || !await native.verifySource(cached.pack)) return false;
|
|
304
|
+
if (fromPersistentIndex) {
|
|
305
|
+
await this.rememberDurablePackInMemory(
|
|
306
|
+
cached.planDigest,
|
|
307
|
+
current,
|
|
308
|
+
target,
|
|
309
|
+
scopePaths,
|
|
310
|
+
cached.pack,
|
|
311
|
+
`durable-cache:${cached.planDigest}`,
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
245
316
|
} catch {
|
|
246
317
|
return false;
|
|
247
318
|
}
|
|
@@ -287,7 +358,7 @@ export class RestoreEngine {
|
|
|
287
358
|
throw error;
|
|
288
359
|
}
|
|
289
360
|
await this.rememberDurablePack(
|
|
290
|
-
plan,
|
|
361
|
+
plan.planDigest,
|
|
291
362
|
current,
|
|
292
363
|
target,
|
|
293
364
|
scopePaths,
|
|
@@ -298,41 +369,245 @@ export class RestoreEngine {
|
|
|
298
369
|
}
|
|
299
370
|
|
|
300
371
|
private async rememberDurablePack(
|
|
301
|
-
|
|
372
|
+
planDigest: string,
|
|
302
373
|
current: SnapshotManifest,
|
|
303
374
|
target: SnapshotManifest,
|
|
304
375
|
scopePaths: readonly string[],
|
|
305
376
|
pack: DurablePack,
|
|
306
377
|
pinReason: string,
|
|
307
378
|
): Promise<void> {
|
|
308
|
-
this.
|
|
379
|
+
await this.rememberDurablePackInMemory(planDigest, current, target, scopePaths, pack, pinReason, true);
|
|
380
|
+
await this.enqueueDurableIndex(async () => {
|
|
381
|
+
const lease = await this.acquireDurableIndexLease();
|
|
382
|
+
if (lease === undefined) return;
|
|
383
|
+
try {
|
|
384
|
+
const disk = await this.readDurableIndexFromDisk();
|
|
385
|
+
if (disk.kind === "unavailable") return;
|
|
386
|
+
this.durableIndex = disk.kind === "ok"
|
|
387
|
+
? disk.index
|
|
388
|
+
: { schemaVersion: 1, entries: [] };
|
|
389
|
+
this.upsertDurableIndexEntry({
|
|
390
|
+
currentManifestId: current.manifestId,
|
|
391
|
+
targetManifestId: target.manifestId,
|
|
392
|
+
scopePaths: [...scopePaths],
|
|
393
|
+
planDigest,
|
|
394
|
+
packChecksum: pack.packChecksum,
|
|
395
|
+
});
|
|
396
|
+
await this.evictPersistedDurablePacks(planDigest);
|
|
397
|
+
await this.persistDurableIndex();
|
|
398
|
+
} finally {
|
|
399
|
+
await lease.release().catch(() => {});
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async rememberDurablePackInMemory(
|
|
405
|
+
planDigest: string,
|
|
406
|
+
current: SnapshotManifest,
|
|
407
|
+
target: SnapshotManifest,
|
|
408
|
+
scopePaths: readonly string[],
|
|
409
|
+
pack: DurablePack,
|
|
410
|
+
pinReason: string,
|
|
411
|
+
pinsDeferred: boolean,
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
this.durablePackCache.delete(planDigest);
|
|
414
|
+
this.durablePackCache.set(planDigest, {
|
|
309
415
|
currentManifestId: current.manifestId,
|
|
310
416
|
targetManifestId: target.manifestId,
|
|
311
417
|
path: pack.storagePath,
|
|
312
418
|
packChecksum: pack.packChecksum,
|
|
313
419
|
pinReason,
|
|
420
|
+
pinsDeferred,
|
|
314
421
|
});
|
|
315
|
-
this.
|
|
316
|
-
|
|
422
|
+
this.trimDurablePackMetadata();
|
|
423
|
+
await this.rememberResidentDurablePack(
|
|
424
|
+
durablePairKey(current.manifestId, target.manifestId, scopePaths),
|
|
425
|
+
planDigest,
|
|
317
426
|
pack,
|
|
318
|
-
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private trimDurablePackMetadata(): void {
|
|
319
431
|
while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
|
|
320
|
-
const oldest = this.durablePackCache.
|
|
321
|
-
|
|
322
|
-
|
|
432
|
+
const oldest = this.durablePackCache.keys().next().value as string | undefined;
|
|
433
|
+
if (oldest === undefined) break;
|
|
434
|
+
this.durablePackCache.delete(oldest);
|
|
435
|
+
this.dropResidentDurablePacks(oldest);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private async rememberResidentDurablePack(
|
|
440
|
+
pairKey: string,
|
|
441
|
+
planDigest: string,
|
|
442
|
+
pack: DurablePack,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const bytes = await this.durablePackFileBytes(pack.storagePath);
|
|
445
|
+
const previous = this.durablePackByPair.get(pairKey);
|
|
446
|
+
if (previous !== undefined) {
|
|
447
|
+
this.durablePackMemoryBytes -= previous.bytes;
|
|
448
|
+
this.durablePackByPair.delete(pairKey);
|
|
449
|
+
}
|
|
450
|
+
this.durablePackByPair.set(pairKey, { planDigest, pack, bytes });
|
|
451
|
+
this.durablePackMemoryBytes += bytes;
|
|
452
|
+
while (this.durablePackMemoryBytes > this.durablePackCacheMaxBytes) {
|
|
453
|
+
const oldest = this.durablePackByPair.keys().next().value as string | undefined;
|
|
323
454
|
if (oldest === undefined) break;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
455
|
+
this.dropResidentDurablePack(oldest);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private dropResidentDurablePack(pairKey: string): void {
|
|
460
|
+
const cached = this.durablePackByPair.get(pairKey);
|
|
461
|
+
if (cached === undefined) return;
|
|
462
|
+
this.durablePackByPair.delete(pairKey);
|
|
463
|
+
this.durablePackMemoryBytes = Math.max(0, this.durablePackMemoryBytes - cached.bytes);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private dropResidentDurablePacks(planDigest: string): void {
|
|
467
|
+
for (const [pairKey, cached] of this.durablePackByPair) {
|
|
468
|
+
if (cached.planDigest === planDigest) this.dropResidentDurablePack(pairKey);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private async readIndexedDurablePack(
|
|
473
|
+
currentManifestId: ManifestId,
|
|
474
|
+
targetManifestId: ManifestId,
|
|
475
|
+
scopePaths: readonly string[],
|
|
476
|
+
): Promise<CachedDurablePair | undefined> {
|
|
477
|
+
const pairKey = durablePairKey(currentManifestId, targetManifestId, scopePaths);
|
|
478
|
+
const existing = this.durablePackByPair.get(pairKey);
|
|
479
|
+
if (existing !== undefined && this.durablePackCache.has(existing.planDigest)) return existing;
|
|
480
|
+
if (existing !== undefined) this.dropResidentDurablePack(pairKey);
|
|
481
|
+
const disk = await this.enqueueDurableIndex(async () => {
|
|
482
|
+
const result = await this.readDurableIndexFromDisk();
|
|
483
|
+
if (result.kind === "ok") this.durableIndex = result.index;
|
|
484
|
+
return result;
|
|
485
|
+
});
|
|
486
|
+
const entries = disk.kind === "ok"
|
|
487
|
+
? disk.index.entries
|
|
488
|
+
: disk.kind === "unavailable" ? this.durableIndex?.entries : undefined;
|
|
489
|
+
const entry = entries?.find((candidate) =>
|
|
490
|
+
durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) === pairKey);
|
|
491
|
+
if (entry === undefined || !isDigest(entry.planDigest) || !isDigest(entry.packChecksum)) return undefined;
|
|
492
|
+
try {
|
|
493
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
494
|
+
const journal = new MutationJournal(
|
|
495
|
+
join(cacheRoot, entry.planDigest, "mutations.jsonl"),
|
|
496
|
+
`cache-${entry.planDigest}`,
|
|
497
|
+
);
|
|
498
|
+
if (!await hasDurablePack(journal)) return undefined;
|
|
499
|
+
const pack = await loadDurablePack(journal, entry.planDigest, true);
|
|
500
|
+
if (pack.packChecksum !== entry.packChecksum || pack.planDigest !== entry.planDigest) return undefined;
|
|
501
|
+
return {
|
|
502
|
+
planDigest: entry.planDigest,
|
|
503
|
+
pack,
|
|
504
|
+
bytes: await this.durablePackFileBytes(pack.storagePath),
|
|
505
|
+
};
|
|
506
|
+
} catch {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private enqueueDurableIndex<T>(operation: () => Promise<T>): Promise<T> {
|
|
512
|
+
const run = this.durableIndexQueue.then(operation, operation);
|
|
513
|
+
this.durableIndexQueue = run.then(() => undefined, () => undefined);
|
|
514
|
+
return run;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
|
|
518
|
+
try {
|
|
519
|
+
return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
|
|
520
|
+
} catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async durableIndexLockIdentity(): Promise<string> {
|
|
526
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
527
|
+
try {
|
|
528
|
+
return `durable-cache:${await realpath(cacheRoot)}`;
|
|
529
|
+
} catch {
|
|
530
|
+
return `durable-cache:${resolve(cacheRoot)}`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async readDurableIndexFromDisk(): Promise<DurableIndexDisk> {
|
|
535
|
+
try {
|
|
536
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
537
|
+
const raw = await readFile(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), "utf8");
|
|
538
|
+
try {
|
|
539
|
+
return { kind: "ok", index: parseDurableCacheIndex(JSON.parse(raw)) };
|
|
540
|
+
} catch {
|
|
541
|
+
return { kind: "corrupt" };
|
|
328
542
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (hasErrorCode(error, "ENOENT")) return { kind: "missing" };
|
|
545
|
+
return { kind: "unavailable" };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private upsertDurableIndexEntry(entry: DurableCacheIndexEntry): void {
|
|
550
|
+
const index = this.durableIndex ?? { schemaVersion: 1, entries: [] };
|
|
551
|
+
const pairKey = durablePairKey(entry.currentManifestId, entry.targetManifestId, entry.scopePaths);
|
|
552
|
+
index.entries = index.entries.filter((candidate) =>
|
|
553
|
+
candidate.planDigest !== entry.planDigest &&
|
|
554
|
+
durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) !== pairKey);
|
|
555
|
+
index.entries.push(entry);
|
|
556
|
+
this.durableIndex = index;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private async persistDurableIndex(): Promise<void> {
|
|
560
|
+
if (this.durableIndex === undefined) return;
|
|
561
|
+
try {
|
|
562
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
563
|
+
await writeJsonAtomic(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), {
|
|
564
|
+
schemaVersion: 1,
|
|
565
|
+
entries: this.durableIndex.entries,
|
|
566
|
+
});
|
|
567
|
+
} catch {
|
|
568
|
+
// 索引只是跨会话候选提示;写入失败不得让已经 pin 成功的 pack 准备失败。
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async evictPersistedDurablePacks(keepPlanDigest: string): Promise<void> {
|
|
573
|
+
while (this.durableIndex !== undefined && this.durableIndex.entries.length > PREPARED_PLAN_CACHE_LIMIT) {
|
|
574
|
+
const oldest = this.durableIndex.entries.find((entry) => entry.planDigest !== keepPlanDigest);
|
|
575
|
+
if (oldest === undefined) break;
|
|
576
|
+
await this.dropPersistedDurablePack(oldest.planDigest);
|
|
333
577
|
}
|
|
334
578
|
}
|
|
335
579
|
|
|
580
|
+
private async dropPersistedDurablePack(planDigest: string): Promise<void> {
|
|
581
|
+
const cached = this.durablePackCache.get(planDigest);
|
|
582
|
+
const indexed = this.durableIndex?.entries.find((entry) => entry.planDigest === planDigest);
|
|
583
|
+
this.durablePackCache.delete(planDigest);
|
|
584
|
+
this.dropResidentDurablePacks(planDigest);
|
|
585
|
+
if (this.durableIndex !== undefined) {
|
|
586
|
+
this.durableIndex.entries = this.durableIndex.entries.filter((entry) => entry.planDigest !== planDigest);
|
|
587
|
+
}
|
|
588
|
+
const pinReason = cached?.pinReason ?? `durable-cache:${planDigest}`;
|
|
589
|
+
const manifests = [...new Set([
|
|
590
|
+
...(cached === undefined ? [] : [cached.currentManifestId, cached.targetManifestId]),
|
|
591
|
+
...(indexed === undefined ? [] : [indexed.currentManifestId, indexed.targetManifestId]),
|
|
592
|
+
])];
|
|
593
|
+
await Promise.all(manifests.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
|
|
594
|
+
try {
|
|
595
|
+
await rm(join(await this.store.durableCacheDirectory(), planDigest), { recursive: true, force: true });
|
|
596
|
+
} catch {
|
|
597
|
+
// 淘汰失败只影响缓存占用,不影响当前 restore 正确性。
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private async durablePackFileBytes(path: string): Promise<number> {
|
|
602
|
+
try {
|
|
603
|
+
const stats = await lstat(path);
|
|
604
|
+
if (stats.isFile() && !stats.isSymbolicLink() && stats.size > 0) return Number(stats.size);
|
|
605
|
+
} catch {
|
|
606
|
+
// 文件可能在加载后被其他进程淘汰;不能把已加载的 pack 当作零字节常驻。
|
|
607
|
+
}
|
|
608
|
+
return this.durablePackCacheMaxBytes + 1;
|
|
609
|
+
}
|
|
610
|
+
|
|
336
611
|
private rememberPreparedPlan(
|
|
337
612
|
plan: RestorePlan,
|
|
338
613
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
@@ -391,8 +666,9 @@ export class RestoreEngine {
|
|
|
391
666
|
if (!hasValidPlanDigest(plan)) {
|
|
392
667
|
throw new Error("restore plan digest 与语义字段不匹配");
|
|
393
668
|
}
|
|
669
|
+
const cachedDurablePack = this.durablePackCache.get(plan.planDigest);
|
|
394
670
|
const pinsDeferred = effectiveOptions.deferDurability === true &&
|
|
395
|
-
|
|
671
|
+
cachedDurablePack?.pinsDeferred === true;
|
|
396
672
|
const recoveryReason = `restore:${plan.planDigest}`;
|
|
397
673
|
const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
|
|
398
674
|
const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
|
|
@@ -946,10 +1222,7 @@ export class RestoreEngine {
|
|
|
946
1222
|
target: SnapshotManifest,
|
|
947
1223
|
actual: RootTopology,
|
|
948
1224
|
): void {
|
|
949
|
-
if (
|
|
950
|
-
actual.workspaceIdentity !== current.workspaceIdentity ||
|
|
951
|
-
actual.fingerprint !== current.topologyFingerprint
|
|
952
|
-
) {
|
|
1225
|
+
if (!sameTopologyModuloIdentity(actual, current)) {
|
|
953
1226
|
throw new Error("apply 前 workspace topology 与 current manifest 不一致");
|
|
954
1227
|
}
|
|
955
1228
|
const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
|
|
@@ -958,14 +1231,6 @@ export class RestoreEngine {
|
|
|
958
1231
|
if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
|
|
959
1232
|
throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
|
|
960
1233
|
}
|
|
961
|
-
if (
|
|
962
|
-
currentRoot !== undefined &&
|
|
963
|
-
targetRoot.state === "active" &&
|
|
964
|
-
(currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
|
|
965
|
-
currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
|
|
966
|
-
) {
|
|
967
|
-
throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
|
|
968
|
-
}
|
|
969
1234
|
}
|
|
970
1235
|
}
|
|
971
1236
|
|
|
@@ -1744,6 +2009,28 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
|
|
|
1744
2009
|
left.linkText === right.linkText;
|
|
1745
2010
|
}
|
|
1746
2011
|
|
|
2012
|
+
// sourceIdentity/privateRepositoryId 会随 git remote 配置漂移(例如后来补充 remote origin),
|
|
2013
|
+
// 它们是仓库元数据而非工作区内容;restore 的内容安全由逐文件校验
|
|
2014
|
+
// (verifyKnownState、assertCompleteVisibleSubset、verifyTarget)保证。
|
|
2015
|
+
// 因此这里只比较结构性拓扑字段:root 集合、parentRoot、state 与 gitlinkOid。
|
|
2016
|
+
function sameTopologyModuloIdentity(actual: RootTopology, expected: SnapshotManifest): boolean {
|
|
2017
|
+
if (actual.workspaceIdentity !== expected.workspaceIdentity) return false;
|
|
2018
|
+
const expectedRoots = new Map(expected.roots.map((root) => [root.relativeRoot, root]));
|
|
2019
|
+
if (actual.roots.length !== expectedRoots.size) return false;
|
|
2020
|
+
for (const root of actual.roots) {
|
|
2021
|
+
const expectedRoot = expectedRoots.get(root.relativeRoot);
|
|
2022
|
+
if (
|
|
2023
|
+
expectedRoot === undefined ||
|
|
2024
|
+
expectedRoot.parentRoot !== root.parentRoot ||
|
|
2025
|
+
expectedRoot.state !== root.state ||
|
|
2026
|
+
(expectedRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null)
|
|
2027
|
+
) {
|
|
2028
|
+
return false;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return true;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1747
2034
|
function assertCompatibleManifests(
|
|
1748
2035
|
current: SnapshotManifest,
|
|
1749
2036
|
target: SnapshotManifest,
|
|
@@ -1995,6 +2282,40 @@ function durablePairKey(
|
|
|
1995
2282
|
return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
|
|
1996
2283
|
}
|
|
1997
2284
|
|
|
2285
|
+
function isDigest(value: unknown): value is string {
|
|
2286
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function parseDurableCacheIndex(value: unknown): DurableCacheIndex {
|
|
2290
|
+
if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
|
|
2291
|
+
throw new Error("durable cache index 无效");
|
|
2292
|
+
}
|
|
2293
|
+
const entries: DurableCacheIndexEntry[] = [];
|
|
2294
|
+
for (const entry of value.entries) {
|
|
2295
|
+
if (
|
|
2296
|
+
!isRecord(entry) ||
|
|
2297
|
+
!isDigest(entry.currentManifestId) ||
|
|
2298
|
+
!isDigest(entry.targetManifestId) ||
|
|
2299
|
+
!isDigest(entry.planDigest) ||
|
|
2300
|
+
!isDigest(entry.packChecksum) ||
|
|
2301
|
+
!Array.isArray(entry.scopePaths) ||
|
|
2302
|
+
entry.scopePaths.some((path) => typeof path !== "string")
|
|
2303
|
+
) continue;
|
|
2304
|
+
entries.push({
|
|
2305
|
+
currentManifestId: entry.currentManifestId as ManifestId,
|
|
2306
|
+
targetManifestId: entry.targetManifestId as ManifestId,
|
|
2307
|
+
scopePaths: entry.scopePaths.filter((path): path is string => typeof path === "string"),
|
|
2308
|
+
planDigest: entry.planDigest,
|
|
2309
|
+
packChecksum: entry.packChecksum,
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
return { schemaVersion: 1, entries };
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2316
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
1998
2319
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1999
2320
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
2000
2321
|
}
|
package/src/root-discovery.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { checksum, topologyFingerprint } from "./encoding.ts";
|
|
@@ -6,6 +6,7 @@ import { GitRunner } from "./git-runner.ts";
|
|
|
6
6
|
import type { DiscoveryRoot } from "./model.ts";
|
|
7
7
|
|
|
8
8
|
const DIRECTORY_SCAN_CONCURRENCY = 16;
|
|
9
|
+
const GIT_POINTER_MAX_BYTES = 4096;
|
|
9
10
|
|
|
10
11
|
interface RepositoryInfo {
|
|
11
12
|
readonly absoluteRoot: string;
|
|
@@ -17,6 +18,7 @@ interface RepositoryInfo {
|
|
|
17
18
|
type RepositoryInspection =
|
|
18
19
|
| { readonly kind: "active"; readonly repository: RepositoryInfo }
|
|
19
20
|
| { readonly kind: "broken"; readonly absoluteRoot: string }
|
|
21
|
+
| { readonly kind: "stale"; readonly absoluteRoot: string }
|
|
20
22
|
| { readonly kind: "absent" };
|
|
21
23
|
|
|
22
24
|
interface DiscoveredRoot {
|
|
@@ -68,7 +70,7 @@ export class RootDiscovery {
|
|
|
68
70
|
outerRepository.repository.absoluteRoot,
|
|
69
71
|
this.activeRoot(workspaceIdentity, outerRepository.repository),
|
|
70
72
|
);
|
|
71
|
-
} else if (outerRepository.kind === "broken") {
|
|
73
|
+
} else if (outerRepository.kind === "broken" || outerRepository.kind === "stale") {
|
|
72
74
|
activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot));
|
|
73
75
|
} else {
|
|
74
76
|
activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity));
|
|
@@ -117,6 +119,8 @@ export class RootDiscovery {
|
|
|
117
119
|
);
|
|
118
120
|
} else if (inspection.kind === "broken") {
|
|
119
121
|
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
122
|
+
} else if (inspection.kind === "stale") {
|
|
123
|
+
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
120
124
|
}
|
|
121
125
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
122
126
|
}
|
|
@@ -145,6 +149,9 @@ export class RootDiscovery {
|
|
|
145
149
|
if (marker === "invalid") {
|
|
146
150
|
return { kind: "broken", absoluteRoot };
|
|
147
151
|
}
|
|
152
|
+
if (await deadWorktreePointer(absoluteRoot)) {
|
|
153
|
+
return { kind: "stale", absoluteRoot };
|
|
154
|
+
}
|
|
148
155
|
|
|
149
156
|
const details = await this.gitOutput([
|
|
150
157
|
"-C",
|
|
@@ -274,6 +281,46 @@ function brokenRoot(workspaceIdentity: string, absoluteRoot: string): Discovered
|
|
|
274
281
|
};
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
// git worktree 的 .git 文件是 "gitdir: <path>" 指针;项目从其他机器/位置搬移后,
|
|
285
|
+
// 指针常指向本机不存在的 gitdir(例如虚拟机共享目录的绝对路径)。这种可证明失效的
|
|
286
|
+
// 指针不再代表可用仓库,按未初始化根处理:内容不进入快照,也不会被 restore 触碰。
|
|
287
|
+
async function deadWorktreePointer(absoluteRoot: string): Promise<boolean> {
|
|
288
|
+
const pointerPath = join(absoluteRoot, ".git");
|
|
289
|
+
let content: string;
|
|
290
|
+
try {
|
|
291
|
+
const marker = await lstat(pointerPath);
|
|
292
|
+
if (!marker.isFile()) return false;
|
|
293
|
+
content = await readFile(pointerPath, "utf8");
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
if (content.length > GIT_POINTER_MAX_BYTES) return false;
|
|
298
|
+
const firstLine = content.split("\n", 1)[0] ?? "";
|
|
299
|
+
const match = /^gitdir: (.+)$/.exec(firstLine.trimEnd());
|
|
300
|
+
if (match === null) return false;
|
|
301
|
+
const gitDirectory = isAbsolute(match[1]) ? resolve(match[1]) : resolve(absoluteRoot, match[1]);
|
|
302
|
+
try {
|
|
303
|
+
await stat(gitDirectory);
|
|
304
|
+
return false;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return hasErrorCode(error, "ENOENT");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function staleWorktreeRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot {
|
|
311
|
+
const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot);
|
|
312
|
+
const sourceIdentity = `stale-worktree:${absoluteRoot}`;
|
|
313
|
+
return {
|
|
314
|
+
absoluteRoot,
|
|
315
|
+
relativeRoot,
|
|
316
|
+
gitBacked: true,
|
|
317
|
+
state: "uninitialized",
|
|
318
|
+
sourceIdentity,
|
|
319
|
+
privateRepositoryId: checksum(sourceIdentity),
|
|
320
|
+
treeId: null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
277
324
|
function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] {
|
|
278
325
|
const unique = new Map<string, DiscoveredRoot>();
|
|
279
326
|
for (const root of discovered) {
|