@anionex/dsh-turn-rewind 0.2.2 → 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/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
- private async acquireWorkspace(workspace: string, signal?: AbortSignal): Promise<() => Promise<void>> {
113
- const identity = await ensureGitWorktreeIdentity(workspace, signal)
114
- if (identity.root !== workspace) {
115
- throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 discoverRepository(options.cwd, signal)
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.root, signal)
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
- if (this.config.turnCheckpointMode === 'legacy') {
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 discoverRepositoryRoot(options.cwd, options.signal)
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 discoverRepositoryRoot(options.cwd, options.signal)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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: current.source.state,
479
- ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
480
- ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
481
- ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
482
- headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
483
- operationChanged: manifest.repository.operation !== current.source.state.operation,
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
522
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
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.workspace, options.signal)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 discoverRepository(options.cwd, options.signal)
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.root, options.signal)
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 root = await discoverRepositoryRoot(workspace, signal)
846
- if (root === workspace) {
847
- await this.assertStorageSeparated(root)
848
- release = await this.acquireWorkspace(root, signal)
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
- ...(manifest.repository.head === undefined ? {} : { head: manifest.repository.head }),
1150
- ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
1151
- ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
1152
- stagedPathCount: manifest.repository.stagedPaths.length,
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
- if (manifest.repository.root !== current.root || manifest.repository.commonDir !== current.commonDir) {
1163
- throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree')
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: RestorePointManifest['repository'],
1181
- after: RestorePointManifest['repository'],
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: RestorePointManifest['repository'],
1188
- current: RestorePointManifest['repository'],
1219
+ planned: WorkspaceState,
1220
+ current: WorkspaceState,
1189
1221
  ): void {
1190
- if (planned.root !== current.root
1191
- || planned.commonDir !== current.commonDir
1192
- || planned.head !== current.head
1193
- || planned.branch !== current.branch
1194
- || planned.operation !== current.operation) {
1195
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again')
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 { RepositoryState } from './types.js'
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: RepositoryState
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: RepositoryState, right: RepositoryState): boolean {
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: RepositoryState): string {
543
+ export function gitMetadataParent(state: GitWorkspaceState): string {
499
544
  return dirname(state.commonDir)
500
545
  }
@@ -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 { discoverRepositoryRoot } from './git.js'
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 discoverRepositoryRoot(cwd, captureSignal)
226
+ const workspace = await discoverWorkspaceRoot(cwd, captureSignal)
227
227
  await this.serializeWorkspace(workspace, captureSignal, async () => {
228
228
  try {
229
229
  await this.engine.createTurnCheckpoint({
@@ -455,11 +455,20 @@ export function createRewindHttpHandler(
455
455
  if (request.method === 'POST') {
456
456
  const body = objectBody(await readBody(request))
457
457
  const mode = body.mode
458
- if (mode !== 'code' && mode !== 'both') {
459
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"')
458
+ if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
459
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"')
460
460
  }
461
461
  const sessionId = requiredText(body.sessionId, 'sessionId')
462
462
  const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
463
+ // Messages-only rewind never reads or writes project files, so it needs
464
+ // neither a checkpoint nor an exclusive workspace: it resolves the turn
465
+ // boundary straight from the Session log and forks there.
466
+ if (mode === 'messages') {
467
+ const target = messageTarget(await readSession(ctx, sessionId), messageSeq)
468
+ const fork = await createConversationRestart(ctx, sessionId, target)
469
+ json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId })
470
+ return
471
+ }
463
472
  const checkpointId = requiredText(body.checkpointId, 'checkpointId')
464
473
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
465
474
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
@@ -608,7 +617,7 @@ async function checkpointForRequest(
608
617
  async function createConversationRestart(
609
618
  ctx: ConversationRestartContext,
610
619
  sourceId: string,
611
- checkpoint: MessageCheckpoint,
620
+ checkpoint: MessageTarget,
612
621
  ): Promise<{ readonly sessionId: string }> {
613
622
  const source = await readSession(ctx, sourceId)
614
623
  const current = messageTarget(source, checkpoint.messageSeq)
@@ -724,16 +733,16 @@ async function sharedWorkspaceSessions(
724
733
  ): Promise<readonly string[]> {
725
734
  const listed = agents?.list() ?? []
726
735
  if (listed.length === 0) return []
727
- const root = await discoverRepositoryRoot(cwd)
736
+ const root = await discoverWorkspaceRoot(cwd)
728
737
  const shared: string[] = []
729
738
  for (const agent of listed) {
730
739
  if (agent.status !== 'running') continue
731
740
  const session = agent.session
732
741
  if (session.header.cwd === undefined) continue
733
742
  try {
734
- if (await discoverRepositoryRoot(session.header.cwd) === root) shared.push(session.id)
743
+ if (await discoverWorkspaceRoot(session.header.cwd) === root) shared.push(session.id)
735
744
  } catch {
736
- // A live Session whose cwd is not a valid Git worktree cannot share this worktree.
745
+ // A live Session whose cwd cannot be resolved cannot share this workspace.
737
746
  }
738
747
  }
739
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: RepositorySnapshotSource
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 discoverRepository(options.cwd, options.signal)
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
- const gitObjectFormat = options.gitObjectFormat
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
- && sameRepositoryFence(first.source.state, second.source.state)
139
- && arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
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 RepositoryState {
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