@anionex/dsh-turn-rewind 0.2.3 → 0.3.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/README.md +13 -4
- package/README.zh.md +13 -4
- package/docs/FORMAT.md +5 -1
- package/lib/client.js +7 -0
- package/lib/client.js.map +1 -1
- package/lib/directory.js +162 -0
- package/lib/engine.js +94 -56
- package/lib/git.js +46 -0
- package/lib/rewind-host.js +5 -5
- package/lib/snapshot.js +13 -5
- package/lib/types/directory.d.ts +10 -0
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/git.d.ts +15 -4
- package/lib/types/snapshot.d.ts +2 -2
- package/lib/types/types.d.ts +15 -2
- package/lib/types/workspace.d.ts +37 -0
- package/lib/validate.js +23 -1
- package/lib/workspace.js +45 -0
- package/package.json +5 -2
- package/src/client/index.tsx +7 -0
- package/src/directory.ts +194 -0
- package/src/engine.ts +104 -68
- package/src/git.ts +49 -4
- package/src/rewind-host.ts +5 -5
- package/src/snapshot.ts +14 -6
- package/src/types.ts +18 -2
- package/src/validate.ts +21 -1
- package/src/workspace.ts +66 -0
package/src/engine.ts
CHANGED
|
@@ -4,13 +4,7 @@ import { join, resolve } from 'node:path'
|
|
|
4
4
|
import { lstat, realpath } from 'node:fs/promises'
|
|
5
5
|
import { createDeadline } from './deadline.js'
|
|
6
6
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
7
|
-
import {
|
|
8
|
-
discoverRepository,
|
|
9
|
-
discoverRepositoryRoot,
|
|
10
|
-
ensureGitWorktreeIdentity,
|
|
11
|
-
resolveGitWorktreeRoot,
|
|
12
|
-
sameRepositoryFence,
|
|
13
|
-
} from './git.js'
|
|
7
|
+
import { ensureGitWorktreeIdentity, resolveGitWorktreeRoot } from './git.js'
|
|
14
8
|
import {
|
|
15
9
|
captureGitTurnCheckpoint,
|
|
16
10
|
deleteGitCheckpoint,
|
|
@@ -34,6 +28,7 @@ import {
|
|
|
34
28
|
} from './path-utils.js'
|
|
35
29
|
import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
|
|
36
30
|
import { LedgerStore, type GitCheckpointJournal } from './store.js'
|
|
31
|
+
import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js'
|
|
37
32
|
import {
|
|
38
33
|
LEDGER_FORMAT_VERSION,
|
|
39
34
|
type ChangeLedgerConfig,
|
|
@@ -50,6 +45,7 @@ import {
|
|
|
50
45
|
type SnapshotEntry,
|
|
51
46
|
type WorkspaceChange,
|
|
52
47
|
type WorkspaceOverview,
|
|
48
|
+
type WorkspaceState,
|
|
53
49
|
type WorkspacePurgeReport,
|
|
54
50
|
} from './types.js'
|
|
55
51
|
|
|
@@ -109,12 +105,24 @@ export class ChangeLedgerEngine {
|
|
|
109
105
|
return restoredOperations + await this.reconcileGitCheckpointJournals()
|
|
110
106
|
}
|
|
111
107
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Acquire the exclusive lock for one workspace.
|
|
110
|
+
*
|
|
111
|
+
* A Git worktree also binds the shared per-worktree lock derived from its Git
|
|
112
|
+
* identity; an ordinary directory owns only the durable directory lock.
|
|
113
|
+
*/
|
|
114
|
+
private async acquireWorkspace(
|
|
115
|
+
workspace: Pick<WorkspaceState, 'type' | 'root'>,
|
|
116
|
+
signal?: AbortSignal,
|
|
117
|
+
): Promise<() => Promise<void>> {
|
|
118
|
+
if (workspace.type === 'directory') {
|
|
119
|
+
return this.store.acquire(workspace.root, undefined, undefined, signal)
|
|
120
|
+
}
|
|
121
|
+
const identity = await ensureGitWorktreeIdentity(workspace.root, signal)
|
|
122
|
+
if (identity.root !== workspace.root) {
|
|
123
|
+
throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace.root)}`)
|
|
116
124
|
}
|
|
117
|
-
return this.store.acquire(workspace, identity.lockPath, {
|
|
125
|
+
return this.store.acquire(workspace.root, identity.lockPath, {
|
|
118
126
|
commonDir: identity.commonDir,
|
|
119
127
|
gitDir: identity.gitDir,
|
|
120
128
|
worktreeId: identity.worktreeId,
|
|
@@ -126,7 +134,7 @@ export class ChangeLedgerEngine {
|
|
|
126
134
|
for (const journal of await this.store.listGitCheckpointJournals()) {
|
|
127
135
|
const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace)
|
|
128
136
|
const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace }
|
|
129
|
-
const release = await this.acquireWorkspace(workspace)
|
|
137
|
+
const release = await this.acquireWorkspace({ type: 'git', root: workspace })
|
|
130
138
|
try {
|
|
131
139
|
const manifest = await this.tryReadManifest(workspace, journal.restorePointId)
|
|
132
140
|
if (journal.action === 'publish') {
|
|
@@ -223,9 +231,9 @@ export class ChangeLedgerEngine {
|
|
|
223
231
|
readonly signal?: AbortSignal
|
|
224
232
|
}): Promise<RestorePointSummary> {
|
|
225
233
|
await this.ready
|
|
226
|
-
const source = await
|
|
234
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
227
235
|
await this.assertStorageSeparated(source.state.root)
|
|
228
|
-
const release = await this.acquireWorkspace(source.state
|
|
236
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
229
237
|
try {
|
|
230
238
|
const label = normalizeLabel(options.label)
|
|
231
239
|
const manifest = await this.createLocked({
|
|
@@ -267,9 +275,9 @@ export class ChangeLedgerEngine {
|
|
|
267
275
|
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
|
|
268
276
|
}
|
|
269
277
|
try {
|
|
270
|
-
const source = await
|
|
278
|
+
const source = await discoverWorkspace(options.cwd, this.config, signal)
|
|
271
279
|
await this.assertStorageSeparated(source.state.root)
|
|
272
|
-
const release = await this.acquireWorkspace(source.state
|
|
280
|
+
const release = await this.acquireWorkspace(source.state, signal)
|
|
273
281
|
try {
|
|
274
282
|
const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal)
|
|
275
283
|
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
@@ -289,7 +297,9 @@ export class ChangeLedgerEngine {
|
|
|
289
297
|
|
|
290
298
|
throwIfAborted(signal)
|
|
291
299
|
let manifest: RestorePointManifest
|
|
292
|
-
|
|
300
|
+
// Git-native checkpoints live in the repository object database, so an
|
|
301
|
+
// ordinary directory always uses the ledger's own content-addressed store.
|
|
302
|
+
if (this.config.turnCheckpointMode === 'legacy' || source.state.type === 'directory') {
|
|
293
303
|
manifest = await this.createLocked({
|
|
294
304
|
cwd: source.state.root,
|
|
295
305
|
kind: 'turn',
|
|
@@ -352,21 +362,21 @@ export class ChangeLedgerEngine {
|
|
|
352
362
|
readonly signal?: AbortSignal
|
|
353
363
|
}): Promise<void> {
|
|
354
364
|
await waitWithSignal(this.ready, options.signal)
|
|
355
|
-
const workspace = await
|
|
356
|
-
await this.assertStorageSeparated(workspace)
|
|
365
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal)
|
|
366
|
+
await this.assertStorageSeparated(workspace.root)
|
|
357
367
|
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
358
368
|
try {
|
|
359
369
|
throwIfAborted(options.signal)
|
|
360
|
-
const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest =>
|
|
370
|
+
const existing = (await this.store.listManifests(workspace.root, options.signal)).some(manifest =>
|
|
361
371
|
manifest.kind === 'turn'
|
|
362
372
|
&& manifest.sessionId === options.sessionId
|
|
363
373
|
&& manifest.turn === options.turn
|
|
364
374
|
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
365
375
|
if (existing) {
|
|
366
|
-
await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq)
|
|
376
|
+
await this.store.deleteTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq)
|
|
367
377
|
return
|
|
368
378
|
}
|
|
369
|
-
await this.store.writeTurnCheckpointSkip(workspace, {
|
|
379
|
+
await this.store.writeTurnCheckpointSkip(workspace.root, {
|
|
370
380
|
version: 1,
|
|
371
381
|
sessionId: options.sessionId,
|
|
372
382
|
turn: options.turn,
|
|
@@ -388,12 +398,12 @@ export class ChangeLedgerEngine {
|
|
|
388
398
|
readonly signal?: AbortSignal
|
|
389
399
|
}): Promise<{ readonly reason: string } | undefined> {
|
|
390
400
|
await this.ready
|
|
391
|
-
const workspace = await
|
|
392
|
-
await this.assertStorageSeparated(workspace)
|
|
401
|
+
const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal)
|
|
402
|
+
await this.assertStorageSeparated(workspace.root)
|
|
393
403
|
const release = await this.acquireWorkspace(workspace, options.signal)
|
|
394
404
|
try {
|
|
395
405
|
const skip = await this.store.readTurnCheckpointSkip(
|
|
396
|
-
workspace,
|
|
406
|
+
workspace.root,
|
|
397
407
|
options.sessionId,
|
|
398
408
|
options.turn,
|
|
399
409
|
options.turnStartSeq,
|
|
@@ -412,9 +422,9 @@ export class ChangeLedgerEngine {
|
|
|
412
422
|
readonly signal?: AbortSignal
|
|
413
423
|
}): Promise<RestorePointSummary | undefined> {
|
|
414
424
|
await this.ready
|
|
415
|
-
const source = await
|
|
425
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
416
426
|
await this.assertStorageSeparated(source.state.root)
|
|
417
|
-
const release = await this.acquireWorkspace(source.state
|
|
427
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
418
428
|
try {
|
|
419
429
|
const manifest = (await this.store.listManifests(source.state.root)).find(point =>
|
|
420
430
|
point.kind === 'turn'
|
|
@@ -436,9 +446,9 @@ export class ChangeLedgerEngine {
|
|
|
436
446
|
readonly signal?: AbortSignal
|
|
437
447
|
}): Promise<RestorePointSummary[]> {
|
|
438
448
|
await this.ready
|
|
439
|
-
const source = await
|
|
449
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
440
450
|
await this.assertStorageSeparated(source.state.root)
|
|
441
|
-
const release = await this.acquireWorkspace(source.state
|
|
451
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
442
452
|
try {
|
|
443
453
|
const manifests = await this.store.listManifests(source.state.root)
|
|
444
454
|
return manifests
|
|
@@ -458,9 +468,9 @@ export class ChangeLedgerEngine {
|
|
|
458
468
|
readonly signal?: AbortSignal
|
|
459
469
|
}): Promise<RestorePointInspection> {
|
|
460
470
|
await this.ready
|
|
461
|
-
const source = await
|
|
471
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
462
472
|
await this.assertStorageSeparated(source.state.root)
|
|
463
|
-
const release = await this.acquireWorkspace(source.state
|
|
473
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
464
474
|
try {
|
|
465
475
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
466
476
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -472,15 +482,17 @@ export class ChangeLedgerEngine {
|
|
|
472
482
|
})
|
|
473
483
|
const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries
|
|
474
484
|
if (currentEntries === undefined) throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable')
|
|
485
|
+
const currentState = current.source.state
|
|
486
|
+
assertWorkspaceIdentityCompatible(manifest.repository, currentState)
|
|
475
487
|
return {
|
|
476
488
|
restorePoint: summarize(manifest),
|
|
477
489
|
currentTreeHash: current.treeHash,
|
|
478
|
-
currentRepository:
|
|
479
|
-
...(
|
|
480
|
-
...(
|
|
481
|
-
...(
|
|
482
|
-
headChanged: repositoryHeadChanged(manifest.repository,
|
|
483
|
-
operationChanged: manifest.repository
|
|
490
|
+
currentRepository: currentState,
|
|
491
|
+
...(currentState.type === 'git' && currentState.head !== undefined ? { currentHead: currentState.head } : {}),
|
|
492
|
+
...(currentState.type === 'git' && currentState.branch !== undefined ? { currentBranch: currentState.branch } : {}),
|
|
493
|
+
...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
|
|
494
|
+
headChanged: repositoryHeadChanged(manifest.repository, currentState),
|
|
495
|
+
operationChanged: workspaceOperationChanged(manifest.repository, currentState),
|
|
484
496
|
changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
|
|
485
497
|
}
|
|
486
498
|
} finally {
|
|
@@ -501,9 +513,9 @@ export class ChangeLedgerEngine {
|
|
|
501
513
|
}): Promise<RestorePlan> {
|
|
502
514
|
await this.ready
|
|
503
515
|
this.expirePlans()
|
|
504
|
-
const source = await
|
|
516
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
505
517
|
await this.assertStorageSeparated(source.state.root)
|
|
506
|
-
const release = await this.acquireWorkspace(source.state
|
|
518
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
507
519
|
try {
|
|
508
520
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
509
521
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -518,8 +530,8 @@ export class ChangeLedgerEngine {
|
|
|
518
530
|
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
519
531
|
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
|
|
520
532
|
}
|
|
521
|
-
if (options.expectedRepository !== undefined && !
|
|
522
|
-
throw
|
|
533
|
+
if (options.expectedRepository !== undefined && !sameWorkspaceFence(options.expectedRepository, current.source.state)) {
|
|
534
|
+
throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection')
|
|
523
535
|
}
|
|
524
536
|
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
|
|
525
537
|
const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries)
|
|
@@ -581,7 +593,7 @@ export class ChangeLedgerEngine {
|
|
|
581
593
|
|
|
582
594
|
try {
|
|
583
595
|
await this.assertStorageSeparated(plan.workspace)
|
|
584
|
-
const release = await this.acquireWorkspace(plan.
|
|
596
|
+
const release = await this.acquireWorkspace(plan.repository, options.signal)
|
|
585
597
|
try {
|
|
586
598
|
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
|
|
587
599
|
if (manifest.version === 2) await verifyGitCheckpoint(manifest, options.signal)
|
|
@@ -721,9 +733,9 @@ export class ChangeLedgerEngine {
|
|
|
721
733
|
if (options.confirmation !== `DELETE ${options.restorePointId}`) {
|
|
722
734
|
throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`)
|
|
723
735
|
}
|
|
724
|
-
const source = await
|
|
736
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
725
737
|
await this.assertStorageSeparated(source.state.root)
|
|
726
|
-
const release = await this.acquireWorkspace(source.state
|
|
738
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
727
739
|
try {
|
|
728
740
|
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
729
741
|
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
@@ -740,9 +752,9 @@ export class ChangeLedgerEngine {
|
|
|
740
752
|
/** List restore operations that were interrupted or require manual recovery. */
|
|
741
753
|
async listRecovery(options: { readonly cwd: string; readonly signal?: AbortSignal }): Promise<RecoverySummary[]> {
|
|
742
754
|
await this.ready
|
|
743
|
-
const source = await
|
|
755
|
+
const source = await discoverWorkspace(options.cwd, this.config, options.signal)
|
|
744
756
|
await this.assertStorageSeparated(source.state.root)
|
|
745
|
-
const release = await this.acquireWorkspace(source.state
|
|
757
|
+
const release = await this.acquireWorkspace(source.state, options.signal)
|
|
746
758
|
try {
|
|
747
759
|
return (await this.store.listOperations(source.state.root))
|
|
748
760
|
.filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
|
|
@@ -842,10 +854,10 @@ export class ChangeLedgerEngine {
|
|
|
842
854
|
let liveRepository = false
|
|
843
855
|
let release: () => Promise<void>
|
|
844
856
|
try {
|
|
845
|
-
const
|
|
846
|
-
if (root === workspace) {
|
|
847
|
-
await this.assertStorageSeparated(root)
|
|
848
|
-
release = await this.acquireWorkspace(
|
|
857
|
+
const identity = await discoverWorkspaceIdentity(workspace, signal)
|
|
858
|
+
if (identity.root === workspace) {
|
|
859
|
+
await this.assertStorageSeparated(identity.root)
|
|
860
|
+
release = await this.acquireWorkspace(identity, signal)
|
|
849
861
|
liveRepository = true
|
|
850
862
|
} else {
|
|
851
863
|
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
@@ -1129,11 +1141,13 @@ export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerC
|
|
|
1129
1141
|
}
|
|
1130
1142
|
|
|
1131
1143
|
function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
1144
|
+
const workspace = manifest.repository
|
|
1132
1145
|
return {
|
|
1133
1146
|
format: manifest.version,
|
|
1134
1147
|
id: manifest.id,
|
|
1135
1148
|
kind: manifest.kind,
|
|
1136
1149
|
workspace: manifest.workspace,
|
|
1150
|
+
workspaceType: workspace.type,
|
|
1137
1151
|
...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
|
|
1138
1152
|
...(manifest.label === undefined ? {} : { label: manifest.label }),
|
|
1139
1153
|
...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
|
|
@@ -1146,22 +1160,34 @@ function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
|
1146
1160
|
totalBytes: manifest.totalBytes,
|
|
1147
1161
|
restoreCount: manifest.restoreCount,
|
|
1148
1162
|
...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
|
|
1149
|
-
...(
|
|
1150
|
-
...(
|
|
1151
|
-
...(
|
|
1152
|
-
stagedPathCount:
|
|
1163
|
+
...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
|
|
1164
|
+
...(workspace.type === 'git' && workspace.branch !== undefined ? { branch: workspace.branch } : {}),
|
|
1165
|
+
...(workspace.type === 'git' && workspace.operation !== undefined ? { operation: workspace.operation } : {}),
|
|
1166
|
+
stagedPathCount: workspace.type === 'git' ? workspace.stagedPaths.length : 0,
|
|
1153
1167
|
...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
|
|
1154
1168
|
}
|
|
1155
1169
|
}
|
|
1156
1170
|
|
|
1171
|
+
/** Reject a fence that now describes a different workspace or workspace mode. */
|
|
1172
|
+
function assertWorkspaceIdentityCompatible(before: WorkspaceState, after: WorkspaceState): void {
|
|
1173
|
+
if (before.type !== after.type) {
|
|
1174
|
+
throw new ChangeLedgerError('WORKSPACE_MODE_CHANGED', `workspace mode changed from ${before.type} to ${after.type}`)
|
|
1175
|
+
}
|
|
1176
|
+
if (before.root !== after.root) {
|
|
1177
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED', 'restore point no longer belongs to this workspace root')
|
|
1178
|
+
}
|
|
1179
|
+
if (before.type === 'git' && after.type === 'git' && before.commonDir !== after.commonDir) {
|
|
1180
|
+
throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree')
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1157
1184
|
function assertRepositoryCompatible(
|
|
1158
1185
|
manifest: RestorePointManifest,
|
|
1159
1186
|
current: RestorePointManifest['repository'],
|
|
1160
1187
|
allowHeadChange: boolean,
|
|
1161
1188
|
): void {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1189
|
+
assertWorkspaceIdentityCompatible(manifest.repository, current)
|
|
1190
|
+
if (manifest.repository.type !== 'git' || current.type !== 'git') return
|
|
1165
1191
|
if (manifest.repository.operation !== current.operation) {
|
|
1166
1192
|
throw new ChangeLedgerError(
|
|
1167
1193
|
'GIT_OPERATION_CHANGED',
|
|
@@ -1177,23 +1203,33 @@ function assertRepositoryCompatible(
|
|
|
1177
1203
|
}
|
|
1178
1204
|
|
|
1179
1205
|
function repositoryHeadChanged(
|
|
1180
|
-
before:
|
|
1181
|
-
after:
|
|
1206
|
+
before: WorkspaceState,
|
|
1207
|
+
after: WorkspaceState,
|
|
1182
1208
|
): boolean {
|
|
1209
|
+
if (before.type !== 'git' || after.type !== 'git') return false
|
|
1183
1210
|
return before.head !== after.head || before.branch !== after.branch
|
|
1184
1211
|
}
|
|
1185
1212
|
|
|
1213
|
+
function workspaceOperationChanged(before: WorkspaceState, after: WorkspaceState): boolean {
|
|
1214
|
+
if (before.type !== 'git' || after.type !== 'git') return false
|
|
1215
|
+
return before.operation !== after.operation
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1186
1218
|
function assertPlanRepositoryFresh(
|
|
1187
|
-
planned:
|
|
1188
|
-
current:
|
|
1219
|
+
planned: WorkspaceState,
|
|
1220
|
+
current: WorkspaceState,
|
|
1189
1221
|
): void {
|
|
1190
|
-
if (planned
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1222
|
+
if (!sameWorkspaceFence(planned, current)) {
|
|
1223
|
+
throw workspaceStaleError(planned, current, 'restore planning')
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Stable stale-plan error for the workspace mode that actually drifted. */
|
|
1228
|
+
function workspaceStaleError(before: WorkspaceState, after: WorkspaceState, phase: string): ChangeLedgerError {
|
|
1229
|
+
if (before.type === 'git' && after.type === 'git') {
|
|
1230
|
+
return new ChangeLedgerError('PLAN_STALE_REPOSITORY', `Git repository state changed after ${phase}; inspect and plan again`)
|
|
1196
1231
|
}
|
|
1232
|
+
return new ChangeLedgerError('PLAN_STALE_WORKSPACE', `workspace state changed after ${phase}; inspect and plan again`)
|
|
1197
1233
|
}
|
|
1198
1234
|
|
|
1199
1235
|
function assertJournalMatchesManifest(journal: GitCheckpointJournal, manifest: RestorePointManifest): asserts manifest is RestorePointManifestV2 {
|
package/src/git.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/pr
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
5
5
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
6
6
|
import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js'
|
|
7
|
-
import type {
|
|
7
|
+
import type { GitWorkspaceState } from './types.js'
|
|
8
8
|
|
|
9
9
|
const GIT_MAX_BUFFER = 32 * 1024 * 1024
|
|
10
10
|
|
|
11
11
|
/** Repository discovery result plus the eligible path inventory. */
|
|
12
12
|
export interface RepositorySnapshotSource {
|
|
13
|
-
readonly state:
|
|
13
|
+
readonly state: GitWorkspaceState
|
|
14
14
|
readonly paths: readonly string[]
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -63,6 +63,7 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
63
63
|
|
|
64
64
|
return {
|
|
65
65
|
state: {
|
|
66
|
+
type: 'git',
|
|
66
67
|
root,
|
|
67
68
|
commonDir,
|
|
68
69
|
...(head === undefined ? {} : { head }),
|
|
@@ -74,6 +75,46 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Discover the owning Git worktree, or return undefined when `cwd` is not
|
|
80
|
+
* inside any repository. Every other discovery failure still throws, so a
|
|
81
|
+
* broken repository never silently degrades into an ordinary directory.
|
|
82
|
+
* @param cwd - candidate working directory.
|
|
83
|
+
* @param signal - optional caller cancellation.
|
|
84
|
+
* @returns the snapshot source, or undefined outside every repository.
|
|
85
|
+
*/
|
|
86
|
+
export async function discoverRepositoryOptional(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource | undefined> {
|
|
87
|
+
try {
|
|
88
|
+
return await discoverRepository(cwd, signal)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (isNotRepositoryError(error)) return undefined
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
|
|
96
|
+
export async function discoverRepositoryRootOptional(cwd: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
97
|
+
try {
|
|
98
|
+
return await discoverRepositoryRoot(cwd, signal)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (isNotRepositoryError(error)) return undefined
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether one discovery failure is Git's plain "not a git repository" verdict.
|
|
107
|
+
*
|
|
108
|
+
* `gitEnvironment` pins the C locale, so the message text is stable and this
|
|
109
|
+
* three-way check (code, exit status, message) cannot swallow a real failure.
|
|
110
|
+
*/
|
|
111
|
+
function isNotRepositoryError(error: unknown): boolean {
|
|
112
|
+
return error instanceof ChangeLedgerError
|
|
113
|
+
&& error.code === 'GIT_COMMAND_FAILED'
|
|
114
|
+
&& gitExitCode(error.cause) === 128
|
|
115
|
+
&& error.message.includes('not a git repository')
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
78
119
|
export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
79
120
|
const canonicalCwd = await canonicalDirectory(cwd)
|
|
@@ -131,7 +172,7 @@ export async function resolveGitWorktreeRoot(
|
|
|
131
172
|
}
|
|
132
173
|
|
|
133
174
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
134
|
-
export function sameRepositoryFence(left:
|
|
175
|
+
export function sameRepositoryFence(left: GitWorkspaceState, right: GitWorkspaceState): boolean {
|
|
135
176
|
return left.root === right.root
|
|
136
177
|
&& left.commonDir === right.commonDir
|
|
137
178
|
&& left.head === right.head
|
|
@@ -302,6 +343,10 @@ function gitEnvironment(extra: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv
|
|
|
302
343
|
...extra,
|
|
303
344
|
GIT_OPTIONAL_LOCKS: '0',
|
|
304
345
|
GIT_TERMINAL_PROMPT: '0',
|
|
346
|
+
// Git's diagnostics are matched for the "not a git repository" verdict that
|
|
347
|
+
// selects ordinary-directory mode; pin the C locale so that stays stable.
|
|
348
|
+
LC_ALL: 'C',
|
|
349
|
+
LANG: 'C',
|
|
305
350
|
}
|
|
306
351
|
}
|
|
307
352
|
|
|
@@ -495,6 +540,6 @@ async function publishIdentityFile(path: string, value: string, repairMalformed:
|
|
|
495
540
|
}
|
|
496
541
|
|
|
497
542
|
/** Return the Git metadata directory for diagnostics. */
|
|
498
|
-
export function gitMetadataParent(state:
|
|
543
|
+
export function gitMetadataParent(state: GitWorkspaceState): string {
|
|
499
544
|
return dirname(state.commonDir)
|
|
500
545
|
}
|
package/src/rewind-host.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
3
3
|
import { createDeadline } from './deadline.js'
|
|
4
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
6
|
-
import {
|
|
6
|
+
import { discoverWorkspaceRoot } from './workspace.js'
|
|
7
7
|
|
|
8
8
|
interface SessionEventLike {
|
|
9
9
|
readonly type: string
|
|
@@ -223,7 +223,7 @@ export class TurnCheckpointCoordinator {
|
|
|
223
223
|
this.skips.delete(key)
|
|
224
224
|
const capture = (async () => {
|
|
225
225
|
try {
|
|
226
|
-
const workspace = await
|
|
226
|
+
const workspace = await discoverWorkspaceRoot(cwd, captureSignal)
|
|
227
227
|
await this.serializeWorkspace(workspace, captureSignal, async () => {
|
|
228
228
|
try {
|
|
229
229
|
await this.engine.createTurnCheckpoint({
|
|
@@ -733,16 +733,16 @@ async function sharedWorkspaceSessions(
|
|
|
733
733
|
): Promise<readonly string[]> {
|
|
734
734
|
const listed = agents?.list() ?? []
|
|
735
735
|
if (listed.length === 0) return []
|
|
736
|
-
const root = await
|
|
736
|
+
const root = await discoverWorkspaceRoot(cwd)
|
|
737
737
|
const shared: string[] = []
|
|
738
738
|
for (const agent of listed) {
|
|
739
739
|
if (agent.status !== 'running') continue
|
|
740
740
|
const session = agent.session
|
|
741
741
|
if (session.header.cwd === undefined) continue
|
|
742
742
|
try {
|
|
743
|
-
if (await
|
|
743
|
+
if (await discoverWorkspaceRoot(session.header.cwd) === root) shared.push(session.id)
|
|
744
744
|
} catch {
|
|
745
|
-
// A live Session whose cwd
|
|
745
|
+
// A live Session whose cwd cannot be resolved cannot share this workspace.
|
|
746
746
|
}
|
|
747
747
|
}
|
|
748
748
|
return shared.sort()
|
package/src/snapshot.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { constants, type BigIntStats } from 'node:fs'
|
|
3
3
|
import { lstat, open, readlink } from 'node:fs/promises'
|
|
4
4
|
import { ChangeLedgerError } from './errors.js'
|
|
5
|
-
import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
|
|
6
5
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js'
|
|
7
6
|
import type { LedgerStore } from './store.js'
|
|
7
|
+
import { discoverWorkspace, sameWorkspaceFence, type WorkspaceSnapshotSource } from './workspace.js'
|
|
8
8
|
import type {
|
|
9
9
|
ResolvedChangeLedgerConfig,
|
|
10
10
|
SnapshotEntry,
|
|
@@ -15,7 +15,7 @@ const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024
|
|
|
15
15
|
|
|
16
16
|
/** One captured tree, optionally persisted into the blob store. */
|
|
17
17
|
export interface CapturedTree {
|
|
18
|
-
readonly source:
|
|
18
|
+
readonly source: WorkspaceSnapshotSource
|
|
19
19
|
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
20
20
|
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>
|
|
21
21
|
readonly treeHash: string
|
|
@@ -32,7 +32,7 @@ export async function captureTree(options: {
|
|
|
32
32
|
readonly signal?: AbortSignal
|
|
33
33
|
}): Promise<CapturedTree> {
|
|
34
34
|
throwIfAborted(options.signal)
|
|
35
|
-
const source = await
|
|
35
|
+
const source = await discoverWorkspace(options.cwd, options.config, options.signal)
|
|
36
36
|
if (source.paths.length > options.config.maxFiles) {
|
|
37
37
|
throw new ChangeLedgerError(
|
|
38
38
|
'TOO_MANY_FILES',
|
|
@@ -41,7 +41,9 @@ export async function captureTree(options: {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
44
|
-
|
|
44
|
+
// Git-object capture only applies to a Git worktree; an ordinary directory
|
|
45
|
+
// always stores its own content-addressed blobs.
|
|
46
|
+
const gitObjectFormat = source.state.type === 'git' ? options.gitObjectFormat : undefined
|
|
45
47
|
const gitCapture = gitObjectFormat === undefined
|
|
46
48
|
? undefined
|
|
47
49
|
: {
|
|
@@ -135,8 +137,8 @@ export async function captureStableTree(options: {
|
|
|
135
137
|
})
|
|
136
138
|
const second = await captureTree(options)
|
|
137
139
|
if (first.treeHash === second.treeHash
|
|
138
|
-
&&
|
|
139
|
-
&&
|
|
140
|
+
&& sameWorkspaceFence(first.source.state, second.source.state)
|
|
141
|
+
&& sameStagedPaths(first.source, second.source)
|
|
140
142
|
&& arraysEqual(first.source.paths, second.source.paths)) {
|
|
141
143
|
return second
|
|
142
144
|
}
|
|
@@ -144,6 +146,12 @@ export async function captureStableTree(options: {
|
|
|
144
146
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures')
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/** Whether two captures observed the same index state; ordinary directories have none. */
|
|
150
|
+
function sameStagedPaths(left: WorkspaceSnapshotSource, right: WorkspaceSnapshotSource): boolean {
|
|
151
|
+
if (left.state.type !== 'git' || right.state.type !== 'git') return true
|
|
152
|
+
return arraysEqual(left.state.stagedPaths, right.state.stagedPaths)
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
/** Compute stable path-level differences between two captured trees. */
|
|
148
156
|
export function diffTrees(
|
|
149
157
|
before: Readonly<Record<string, SnapshotEntry>>,
|
package/src/types.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface SymlinkSnapshotEntry {
|
|
|
34
34
|
export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry
|
|
35
35
|
|
|
36
36
|
/** Git facts that fence a restore point to the repository state it observed. */
|
|
37
|
-
export interface
|
|
37
|
+
export interface GitWorkspaceState {
|
|
38
|
+
readonly type: 'git'
|
|
38
39
|
readonly root: string
|
|
39
40
|
readonly commonDir: string
|
|
40
41
|
readonly head?: string
|
|
@@ -43,6 +44,18 @@ export interface RepositoryState {
|
|
|
43
44
|
readonly stagedPaths: readonly string[]
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** Identity of an ordinary directory workspace captured without Git. */
|
|
48
|
+
export interface DirectoryWorkspaceState {
|
|
49
|
+
readonly type: 'directory'
|
|
50
|
+
readonly root: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Workspace facts that fence a restore point to the state it observed. */
|
|
54
|
+
export type WorkspaceState = GitWorkspaceState | DirectoryWorkspaceState
|
|
55
|
+
|
|
56
|
+
/** @deprecated Use `WorkspaceState`; the durable manifest field keeps its original name. */
|
|
57
|
+
export type RepositoryState = WorkspaceState
|
|
58
|
+
|
|
46
59
|
/** Why a restore point exists. */
|
|
47
60
|
export type RestorePointKind = 'user' | 'rescue' | 'turn'
|
|
48
61
|
|
|
@@ -89,8 +102,10 @@ export interface GitCheckpointMetadata {
|
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
/** Automatic turn checkpoint whose content is stored in the repository object database. */
|
|
92
|
-
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
|
|
105
|
+
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash' | 'repository'> {
|
|
93
106
|
readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION
|
|
107
|
+
/** Git-native checkpoints only exist for a Git worktree. */
|
|
108
|
+
readonly repository: GitWorkspaceState
|
|
94
109
|
/** Deterministic sidecar identity; Git tree integrity is verified separately. */
|
|
95
110
|
readonly treeHash: string
|
|
96
111
|
readonly git: GitCheckpointMetadata
|
|
@@ -126,6 +141,7 @@ export interface RestorePointSummary {
|
|
|
126
141
|
readonly id: RestorePointId
|
|
127
142
|
readonly kind: RestorePointKind
|
|
128
143
|
readonly workspace: string
|
|
144
|
+
readonly workspaceType: WorkspaceState['type']
|
|
129
145
|
readonly sessionId?: string
|
|
130
146
|
readonly label?: string
|
|
131
147
|
readonly parentRestorePoint?: RestorePointId
|