@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/lib/engine.js CHANGED
@@ -4,11 +4,12 @@ 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 { discoverRepository, discoverRepositoryRoot, ensureGitWorktreeIdentity, resolveGitWorktreeRoot, sameRepositoryFence, } from './git.js';
7
+ import { ensureGitWorktreeIdentity, resolveGitWorktreeRoot } from './git.js';
8
8
  import { captureGitTurnCheckpoint, deleteGitCheckpoint, deleteGitCheckpointRef, publishGitCheckpoint, readGitBlob, verifyGitCheckpoint, } from './git-checkpoint.js';
9
9
  import { assertSafeParents, ensureSafeParents, expandHome, isNodeError, isWithin, pruneEmptyParents, removeRestoreTarget, replaceRegularFile, replaceSymbolicLink, resolveWorkspacePath, validateRelativePath, } from './path-utils.js';
10
10
  import { captureSnapshotEntry, captureStableTree, diffTrees, entriesEqual } from './snapshot.js';
11
11
  import { LedgerStore } from './store.js';
12
+ import { discoverWorkspace, discoverWorkspaceIdentity, sameWorkspaceFence } from './workspace.js';
12
13
  import { LEDGER_FORMAT_VERSION, } from './types.js';
13
14
  const DEFAULTS = {
14
15
  maxRestorePoints: 50,
@@ -59,12 +60,21 @@ export class ChangeLedgerEngine {
59
60
  const restoredOperations = await this.store.initialize();
60
61
  return restoredOperations + await this.reconcileGitCheckpointJournals();
61
62
  }
63
+ /**
64
+ * Acquire the exclusive lock for one workspace.
65
+ *
66
+ * A Git worktree also binds the shared per-worktree lock derived from its Git
67
+ * identity; an ordinary directory owns only the durable directory lock.
68
+ */
62
69
  async acquireWorkspace(workspace, signal) {
63
- const identity = await ensureGitWorktreeIdentity(workspace, signal);
64
- if (identity.root !== workspace) {
65
- throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace)}`);
70
+ if (workspace.type === 'directory') {
71
+ return this.store.acquire(workspace.root, undefined, undefined, signal);
72
+ }
73
+ const identity = await ensureGitWorktreeIdentity(workspace.root, signal);
74
+ if (identity.root !== workspace.root) {
75
+ throw new ChangeLedgerError('GIT_ROOT_INVALID', `workspace moved while acquiring its lock: ${JSON.stringify(workspace.root)}`);
66
76
  }
67
- return this.store.acquire(workspace, identity.lockPath, {
77
+ return this.store.acquire(workspace.root, identity.lockPath, {
68
78
  commonDir: identity.commonDir,
69
79
  gitDir: identity.gitDir,
70
80
  worktreeId: identity.worktreeId,
@@ -75,7 +85,7 @@ export class ChangeLedgerEngine {
75
85
  for (const journal of await this.store.listGitCheckpointJournals()) {
76
86
  const workspace = await resolveGitWorktreeRoot(journal.commonDir, journal.worktreeId, journal.workspace);
77
87
  const activeJournal = workspace === journal.workspace ? journal : { ...journal, workspace };
78
- const release = await this.acquireWorkspace(workspace);
88
+ const release = await this.acquireWorkspace({ type: 'git', root: workspace });
79
89
  try {
80
90
  const manifest = await this.tryReadManifest(workspace, journal.restorePointId);
81
91
  if (journal.action === 'publish') {
@@ -167,9 +177,9 @@ export class ChangeLedgerEngine {
167
177
  /** Create a durable restore point for the current Git worktree. */
168
178
  async create(options) {
169
179
  await this.ready;
170
- const source = await discoverRepository(options.cwd, options.signal);
180
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
171
181
  await this.assertStorageSeparated(source.state.root);
172
- const release = await this.acquireWorkspace(source.state.root, options.signal);
182
+ const release = await this.acquireWorkspace(source.state, options.signal);
173
183
  try {
174
184
  const label = normalizeLabel(options.label);
175
185
  const manifest = await this.createLocked({
@@ -206,9 +216,9 @@ export class ChangeLedgerEngine {
206
216
  throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer');
207
217
  }
208
218
  try {
209
- const source = await discoverRepository(options.cwd, signal);
219
+ const source = await discoverWorkspace(options.cwd, this.config, signal);
210
220
  await this.assertStorageSeparated(source.state.root);
211
- const release = await this.acquireWorkspace(source.state.root, signal);
221
+ const release = await this.acquireWorkspace(source.state, signal);
212
222
  try {
213
223
  const existing = await waitWithSignal(this.store.listManifests(source.state.root, signal), signal);
214
224
  const duplicate = existing.find(manifest => manifest.kind === 'turn'
@@ -223,7 +233,9 @@ export class ChangeLedgerEngine {
223
233
  }
224
234
  throwIfAborted(signal);
225
235
  let manifest;
226
- if (this.config.turnCheckpointMode === 'legacy') {
236
+ // Git-native checkpoints live in the repository object database, so an
237
+ // ordinary directory always uses the ledger's own content-addressed store.
238
+ if (this.config.turnCheckpointMode === 'legacy' || source.state.type === 'directory') {
227
239
  manifest = await this.createLocked({
228
240
  cwd: source.state.root,
229
241
  kind: 'turn',
@@ -278,20 +290,20 @@ export class ChangeLedgerEngine {
278
290
  /** Persist one bounded automatic-checkpoint skip without blocking a later turn retry. */
279
291
  async recordTurnCheckpointSkip(options) {
280
292
  await waitWithSignal(this.ready, options.signal);
281
- const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
282
- await this.assertStorageSeparated(workspace);
293
+ const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
294
+ await this.assertStorageSeparated(workspace.root);
283
295
  const release = await this.acquireWorkspace(workspace, options.signal);
284
296
  try {
285
297
  throwIfAborted(options.signal);
286
- const existing = (await this.store.listManifests(workspace, options.signal)).some(manifest => manifest.kind === 'turn'
298
+ const existing = (await this.store.listManifests(workspace.root, options.signal)).some(manifest => manifest.kind === 'turn'
287
299
  && manifest.sessionId === options.sessionId
288
300
  && manifest.turn === options.turn
289
301
  && manifest.turnStartSeq === options.turnStartSeq);
290
302
  if (existing) {
291
- await this.store.deleteTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
303
+ await this.store.deleteTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
292
304
  return;
293
305
  }
294
- await this.store.writeTurnCheckpointSkip(workspace, {
306
+ await this.store.writeTurnCheckpointSkip(workspace.root, {
295
307
  version: 1,
296
308
  sessionId: options.sessionId,
297
309
  turn: options.turn,
@@ -307,11 +319,11 @@ export class ChangeLedgerEngine {
307
319
  /** Read a durable skip marker for one exact prompt boundary. */
308
320
  async findTurnCheckpointSkip(options) {
309
321
  await this.ready;
310
- const workspace = await discoverRepositoryRoot(options.cwd, options.signal);
311
- await this.assertStorageSeparated(workspace);
322
+ const workspace = await discoverWorkspaceIdentity(options.cwd, options.signal);
323
+ await this.assertStorageSeparated(workspace.root);
312
324
  const release = await this.acquireWorkspace(workspace, options.signal);
313
325
  try {
314
- const skip = await this.store.readTurnCheckpointSkip(workspace, options.sessionId, options.turn, options.turnStartSeq);
326
+ const skip = await this.store.readTurnCheckpointSkip(workspace.root, options.sessionId, options.turn, options.turnStartSeq);
315
327
  return skip === undefined ? undefined : { reason: skip.reason };
316
328
  }
317
329
  finally {
@@ -321,9 +333,9 @@ export class ChangeLedgerEngine {
321
333
  /** Find the prompt-anchored checkpoint captured before one session turn. */
322
334
  async findTurnCheckpoint(options) {
323
335
  await this.ready;
324
- const source = await discoverRepository(options.cwd, options.signal);
336
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
325
337
  await this.assertStorageSeparated(source.state.root);
326
- const release = await this.acquireWorkspace(source.state.root, options.signal);
338
+ const release = await this.acquireWorkspace(source.state, options.signal);
327
339
  try {
328
340
  const manifest = (await this.store.listManifests(source.state.root)).find(point => point.kind === 'turn'
329
341
  && point.sessionId === options.sessionId
@@ -340,9 +352,9 @@ export class ChangeLedgerEngine {
340
352
  /** List restore points for the current worktree. */
341
353
  async list(options) {
342
354
  await this.ready;
343
- const source = await discoverRepository(options.cwd, options.signal);
355
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
344
356
  await this.assertStorageSeparated(source.state.root);
345
- const release = await this.acquireWorkspace(source.state.root, options.signal);
357
+ const release = await this.acquireWorkspace(source.state, options.signal);
346
358
  try {
347
359
  const manifests = await this.store.listManifests(source.state.root);
348
360
  return manifests
@@ -358,9 +370,9 @@ export class ChangeLedgerEngine {
358
370
  /** Compare one restore point with the current worktree. */
359
371
  async inspect(options) {
360
372
  await this.ready;
361
- const source = await discoverRepository(options.cwd, options.signal);
373
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
362
374
  await this.assertStorageSeparated(source.state.root);
363
- const release = await this.acquireWorkspace(source.state.root, options.signal);
375
+ const release = await this.acquireWorkspace(source.state, options.signal);
364
376
  try {
365
377
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
366
378
  if (manifest.version === 2)
@@ -374,15 +386,17 @@ export class ChangeLedgerEngine {
374
386
  const currentEntries = manifest.version === 2 ? current.gitEntries : current.entries;
375
387
  if (currentEntries === undefined)
376
388
  throw new ChangeLedgerError('STATE_CORRUPT', 'Git comparison entries are unavailable');
389
+ const currentState = current.source.state;
390
+ assertWorkspaceIdentityCompatible(manifest.repository, currentState);
377
391
  return {
378
392
  restorePoint: summarize(manifest),
379
393
  currentTreeHash: current.treeHash,
380
- currentRepository: current.source.state,
381
- ...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
382
- ...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
383
- ...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
384
- headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
385
- operationChanged: manifest.repository.operation !== current.source.state.operation,
394
+ currentRepository: currentState,
395
+ ...(currentState.type === 'git' && currentState.head !== undefined ? { currentHead: currentState.head } : {}),
396
+ ...(currentState.type === 'git' && currentState.branch !== undefined ? { currentBranch: currentState.branch } : {}),
397
+ ...(currentState.type === 'git' && currentState.operation !== undefined ? { currentOperation: currentState.operation } : {}),
398
+ headChanged: repositoryHeadChanged(manifest.repository, currentState),
399
+ operationChanged: workspaceOperationChanged(manifest.repository, currentState),
386
400
  changes: diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries),
387
401
  };
388
402
  }
@@ -394,9 +408,9 @@ export class ChangeLedgerEngine {
394
408
  async planRestore(options) {
395
409
  await this.ready;
396
410
  this.expirePlans();
397
- const source = await discoverRepository(options.cwd, options.signal);
411
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
398
412
  await this.assertStorageSeparated(source.state.root);
399
- const release = await this.acquireWorkspace(source.state.root, options.signal);
413
+ const release = await this.acquireWorkspace(source.state, options.signal);
400
414
  try {
401
415
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
402
416
  if (manifest.version === 2)
@@ -413,8 +427,8 @@ export class ChangeLedgerEngine {
413
427
  if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
414
428
  throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again');
415
429
  }
416
- if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
417
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again');
430
+ if (options.expectedRepository !== undefined && !sameWorkspaceFence(options.expectedRepository, current.source.state)) {
431
+ throw workspaceStaleError(options.expectedRepository, current.source.state, 'inspection');
418
432
  }
419
433
  assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true);
420
434
  const changes = diffTrees(manifest.entries, current.entries, manifest.entries, currentEntries);
@@ -467,7 +481,7 @@ export class ChangeLedgerEngine {
467
481
  this.activePlans.add(plan.id);
468
482
  try {
469
483
  await this.assertStorageSeparated(plan.workspace);
470
- const release = await this.acquireWorkspace(plan.workspace, options.signal);
484
+ const release = await this.acquireWorkspace(plan.repository, options.signal);
471
485
  try {
472
486
  const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId);
473
487
  if (manifest.version === 2)
@@ -590,9 +604,9 @@ export class ChangeLedgerEngine {
590
604
  if (options.confirmation !== `DELETE ${options.restorePointId}`) {
591
605
  throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`);
592
606
  }
593
- const source = await discoverRepository(options.cwd, options.signal);
607
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
594
608
  await this.assertStorageSeparated(source.state.root);
595
- const release = await this.acquireWorkspace(source.state.root, options.signal);
609
+ const release = await this.acquireWorkspace(source.state, options.signal);
596
610
  try {
597
611
  const manifest = await this.store.readManifest(source.state.root, options.restorePointId);
598
612
  if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
@@ -609,9 +623,9 @@ export class ChangeLedgerEngine {
609
623
  /** List restore operations that were interrupted or require manual recovery. */
610
624
  async listRecovery(options) {
611
625
  await this.ready;
612
- const source = await discoverRepository(options.cwd, options.signal);
626
+ const source = await discoverWorkspace(options.cwd, this.config, options.signal);
613
627
  await this.assertStorageSeparated(source.state.root);
614
- const release = await this.acquireWorkspace(source.state.root, options.signal);
628
+ const release = await this.acquireWorkspace(source.state, options.signal);
615
629
  try {
616
630
  return (await this.store.listOperations(source.state.root))
617
631
  .filter((operation) => operation.state === 'interrupted' || operation.state === 'recovery-required')
@@ -698,10 +712,10 @@ export class ChangeLedgerEngine {
698
712
  let liveRepository = false;
699
713
  let release;
700
714
  try {
701
- const root = await discoverRepositoryRoot(workspace, signal);
702
- if (root === workspace) {
703
- await this.assertStorageSeparated(root);
704
- release = await this.acquireWorkspace(root, signal);
715
+ const identity = await discoverWorkspaceIdentity(workspace, signal);
716
+ if (identity.root === workspace) {
717
+ await this.assertStorageSeparated(identity.root);
718
+ release = await this.acquireWorkspace(identity, signal);
705
719
  liveRepository = true;
706
720
  }
707
721
  else {
@@ -938,11 +952,13 @@ export function resolveConfig(config) {
938
952
  };
939
953
  }
940
954
  function summarize(manifest) {
955
+ const workspace = manifest.repository;
941
956
  return {
942
957
  format: manifest.version,
943
958
  id: manifest.id,
944
959
  kind: manifest.kind,
945
960
  workspace: manifest.workspace,
961
+ workspaceType: workspace.type,
946
962
  ...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
947
963
  ...(manifest.label === undefined ? {} : { label: manifest.label }),
948
964
  ...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
@@ -955,17 +971,29 @@ function summarize(manifest) {
955
971
  totalBytes: manifest.totalBytes,
956
972
  restoreCount: manifest.restoreCount,
957
973
  ...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
958
- ...(manifest.repository.head === undefined ? {} : { head: manifest.repository.head }),
959
- ...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
960
- ...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
961
- stagedPathCount: manifest.repository.stagedPaths.length,
974
+ ...(workspace.type === 'git' && workspace.head !== undefined ? { head: workspace.head } : {}),
975
+ ...(workspace.type === 'git' && workspace.branch !== undefined ? { branch: workspace.branch } : {}),
976
+ ...(workspace.type === 'git' && workspace.operation !== undefined ? { operation: workspace.operation } : {}),
977
+ stagedPathCount: workspace.type === 'git' ? workspace.stagedPaths.length : 0,
962
978
  ...(manifest.version === 2 ? { trust: manifest.git.trust } : {}),
963
979
  };
964
980
  }
965
- function assertRepositoryCompatible(manifest, current, allowHeadChange) {
966
- if (manifest.repository.root !== current.root || manifest.repository.commonDir !== current.commonDir) {
981
+ /** Reject a fence that now describes a different workspace or workspace mode. */
982
+ function assertWorkspaceIdentityCompatible(before, after) {
983
+ if (before.type !== after.type) {
984
+ throw new ChangeLedgerError('WORKSPACE_MODE_CHANGED', `workspace mode changed from ${before.type} to ${after.type}`);
985
+ }
986
+ if (before.root !== after.root) {
987
+ throw new ChangeLedgerError('WORKSPACE_CHANGED', 'restore point no longer belongs to this workspace root');
988
+ }
989
+ if (before.type === 'git' && after.type === 'git' && before.commonDir !== after.commonDir) {
967
990
  throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree');
968
991
  }
992
+ }
993
+ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
994
+ assertWorkspaceIdentityCompatible(manifest.repository, current);
995
+ if (manifest.repository.type !== 'git' || current.type !== 'git')
996
+ return;
969
997
  if (manifest.repository.operation !== current.operation) {
970
998
  throw new ChangeLedgerError('GIT_OPERATION_CHANGED', `Git operation changed from ${manifest.repository.operation ?? 'none'} to ${current.operation ?? 'none'}`);
971
999
  }
@@ -974,16 +1002,26 @@ function assertRepositoryCompatible(manifest, current, allowHeadChange) {
974
1002
  }
975
1003
  }
976
1004
  function repositoryHeadChanged(before, after) {
1005
+ if (before.type !== 'git' || after.type !== 'git')
1006
+ return false;
977
1007
  return before.head !== after.head || before.branch !== after.branch;
978
1008
  }
1009
+ function workspaceOperationChanged(before, after) {
1010
+ if (before.type !== 'git' || after.type !== 'git')
1011
+ return false;
1012
+ return before.operation !== after.operation;
1013
+ }
979
1014
  function assertPlanRepositoryFresh(planned, current) {
980
- if (planned.root !== current.root
981
- || planned.commonDir !== current.commonDir
982
- || planned.head !== current.head
983
- || planned.branch !== current.branch
984
- || planned.operation !== current.operation) {
985
- throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again');
1015
+ if (!sameWorkspaceFence(planned, current)) {
1016
+ throw workspaceStaleError(planned, current, 'restore planning');
1017
+ }
1018
+ }
1019
+ /** Stable stale-plan error for the workspace mode that actually drifted. */
1020
+ function workspaceStaleError(before, after, phase) {
1021
+ if (before.type === 'git' && after.type === 'git') {
1022
+ return new ChangeLedgerError('PLAN_STALE_REPOSITORY', `Git repository state changed after ${phase}; inspect and plan again`);
986
1023
  }
1024
+ return new ChangeLedgerError('PLAN_STALE_WORKSPACE', `workspace state changed after ${phase}; inspect and plan again`);
987
1025
  }
988
1026
  function assertJournalMatchesManifest(journal, manifest) {
989
1027
  if (manifest.version !== 2
package/lib/git.js CHANGED
@@ -37,6 +37,7 @@ export async function discoverRepository(cwd, signal) {
37
37
  const operation = await gitOperation(gitDir);
38
38
  return {
39
39
  state: {
40
+ type: 'git',
40
41
  root,
41
42
  commonDir,
42
43
  ...(head === undefined ? {} : { head }),
@@ -47,6 +48,47 @@ export async function discoverRepository(cwd, signal) {
47
48
  paths,
48
49
  };
49
50
  }
51
+ /**
52
+ * Discover the owning Git worktree, or return undefined when `cwd` is not
53
+ * inside any repository. Every other discovery failure still throws, so a
54
+ * broken repository never silently degrades into an ordinary directory.
55
+ * @param cwd - candidate working directory.
56
+ * @param signal - optional caller cancellation.
57
+ * @returns the snapshot source, or undefined outside every repository.
58
+ */
59
+ export async function discoverRepositoryOptional(cwd, signal) {
60
+ try {
61
+ return await discoverRepository(cwd, signal);
62
+ }
63
+ catch (error) {
64
+ if (isNotRepositoryError(error))
65
+ return undefined;
66
+ throw error;
67
+ }
68
+ }
69
+ /** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
70
+ export async function discoverRepositoryRootOptional(cwd, signal) {
71
+ try {
72
+ return await discoverRepositoryRoot(cwd, signal);
73
+ }
74
+ catch (error) {
75
+ if (isNotRepositoryError(error))
76
+ return undefined;
77
+ throw error;
78
+ }
79
+ }
80
+ /**
81
+ * Whether one discovery failure is Git's plain "not a git repository" verdict.
82
+ *
83
+ * `gitEnvironment` pins the C locale, so the message text is stable and this
84
+ * three-way check (code, exit status, message) cannot swallow a real failure.
85
+ */
86
+ function isNotRepositoryError(error) {
87
+ return error instanceof ChangeLedgerError
88
+ && error.code === 'GIT_COMMAND_FAILED'
89
+ && gitExitCode(error.cause) === 128
90
+ && error.message.includes('not a git repository');
91
+ }
50
92
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
51
93
  export async function discoverRepositoryRoot(cwd, signal) {
52
94
  const canonicalCwd = await canonicalDirectory(cwd);
@@ -237,6 +279,10 @@ function gitEnvironment(extra) {
237
279
  ...extra,
238
280
  GIT_OPTIONAL_LOCKS: '0',
239
281
  GIT_TERMINAL_PROMPT: '0',
282
+ // Git's diagnostics are matched for the "not a git repository" verdict that
283
+ // selects ordinary-directory mode; pin the C locale so that stays stable.
284
+ LC_ALL: 'C',
285
+ LANG: 'C',
240
286
  };
241
287
  }
242
288
  function gitExitCode(error) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { createDeadline } from './deadline.js';
3
3
  import { ChangeLedgerError, errorMessage } from './errors.js';
4
- import { discoverRepositoryRoot } from './git.js';
4
+ import { discoverWorkspaceRoot } from './workspace.js';
5
5
  function getSessionEvents(session) {
6
6
  if (!session)
7
7
  return [];
@@ -82,7 +82,7 @@ export class TurnCheckpointCoordinator {
82
82
  this.skips.delete(key);
83
83
  const capture = (async () => {
84
84
  try {
85
- const workspace = await discoverRepositoryRoot(cwd, captureSignal);
85
+ const workspace = await discoverWorkspaceRoot(cwd, captureSignal);
86
86
  await this.serializeWorkspace(workspace, captureSignal, async () => {
87
87
  try {
88
88
  await this.engine.createTurnCheckpoint({
@@ -291,11 +291,20 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
291
291
  if (request.method === 'POST') {
292
292
  const body = objectBody(await readBody(request));
293
293
  const mode = body.mode;
294
- if (mode !== 'code' && mode !== 'both') {
295
- throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"');
294
+ if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
295
+ throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"');
296
296
  }
297
297
  const sessionId = requiredText(body.sessionId, 'sessionId');
298
298
  const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq');
299
+ // Messages-only rewind never reads or writes project files, so it needs
300
+ // neither a checkpoint nor an exclusive workspace: it resolves the turn
301
+ // boundary straight from the Session log and forks there.
302
+ if (mode === 'messages') {
303
+ const target = messageTarget(await readSession(ctx, sessionId), messageSeq);
304
+ const fork = await createConversationRestart(ctx, sessionId, target);
305
+ json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId });
306
+ return;
307
+ }
299
308
  const checkpointId = requiredText(body.checkpointId, 'checkpointId');
300
309
  const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
301
310
  const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
@@ -512,7 +521,7 @@ async function sharedWorkspaceSessions(agents, cwd) {
512
521
  const listed = agents?.list() ?? [];
513
522
  if (listed.length === 0)
514
523
  return [];
515
- const root = await discoverRepositoryRoot(cwd);
524
+ const root = await discoverWorkspaceRoot(cwd);
516
525
  const shared = [];
517
526
  for (const agent of listed) {
518
527
  if (agent.status !== 'running')
@@ -521,11 +530,11 @@ async function sharedWorkspaceSessions(agents, cwd) {
521
530
  if (session.header.cwd === undefined)
522
531
  continue;
523
532
  try {
524
- if (await discoverRepositoryRoot(session.header.cwd) === root)
533
+ if (await discoverWorkspaceRoot(session.header.cwd) === root)
525
534
  shared.push(session.id);
526
535
  }
527
536
  catch {
528
- // A live Session whose cwd is not a valid Git worktree cannot share this worktree.
537
+ // A live Session whose cwd cannot be resolved cannot share this workspace.
529
538
  }
530
539
  }
531
540
  return shared.sort();
package/lib/snapshot.js CHANGED
@@ -2,18 +2,20 @@ import { createHash } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, open, readlink } from 'node:fs/promises';
4
4
  import { ChangeLedgerError } from './errors.js';
5
- import { discoverRepository, sameRepositoryFence } from './git.js';
6
5
  import { isNodeError, resolveWorkspacePath } from './path-utils.js';
6
+ import { discoverWorkspace, sameWorkspaceFence } from './workspace.js';
7
7
  const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024;
8
8
  /** Capture the current tracked and non-ignored Git working tree. */
9
9
  export async function captureTree(options) {
10
10
  throwIfAborted(options.signal);
11
- const source = await discoverRepository(options.cwd, options.signal);
11
+ const source = await discoverWorkspace(options.cwd, options.config, options.signal);
12
12
  if (source.paths.length > options.config.maxFiles) {
13
13
  throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
14
14
  }
15
15
  const entries = Object.create(null);
16
- const gitObjectFormat = options.gitObjectFormat;
16
+ // Git-object capture only applies to a Git worktree; an ordinary directory
17
+ // always stores its own content-addressed blobs.
18
+ const gitObjectFormat = source.state.type === 'git' ? options.gitObjectFormat : undefined;
17
19
  const gitCapture = gitObjectFormat === undefined
18
20
  ? undefined
19
21
  : {
@@ -99,14 +101,20 @@ export async function captureStableTree(options) {
99
101
  });
100
102
  const second = await captureTree(options);
101
103
  if (first.treeHash === second.treeHash
102
- && sameRepositoryFence(first.source.state, second.source.state)
103
- && arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
104
+ && sameWorkspaceFence(first.source.state, second.source.state)
105
+ && sameStagedPaths(first.source, second.source)
104
106
  && arraysEqual(first.source.paths, second.source.paths)) {
105
107
  return second;
106
108
  }
107
109
  }
108
110
  throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
109
111
  }
112
+ /** Whether two captures observed the same index state; ordinary directories have none. */
113
+ function sameStagedPaths(left, right) {
114
+ if (left.state.type !== 'git' || right.state.type !== 'git')
115
+ return true;
116
+ return arraysEqual(left.state.stagedPaths, right.state.stagedPaths);
117
+ }
110
118
  /** Compute stable path-level differences between two captured trees. */
111
119
  export function diffTrees(before, after, comparisonBefore = before, comparisonAfter = after) {
112
120
  const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
@@ -0,0 +1,10 @@
1
+ import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js';
2
+ /** Ordinary-directory discovery result plus its eligible path inventory. */
3
+ export interface DirectorySnapshotSource {
4
+ readonly state: DirectoryWorkspaceState;
5
+ readonly paths: readonly string[];
6
+ }
7
+ /** Resolve the canonical ordinary directory used as a non-Git workspace root. */
8
+ export declare function discoverDirectoryRoot(cwd: string): Promise<string>;
9
+ /** Enumerate regular files and symlinks in an ordinary directory using rewind ignore rules. */
10
+ export declare function discoverDirectory(cwd: string, config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>, signal?: AbortSignal): Promise<DirectorySnapshotSource>;
@@ -19,6 +19,12 @@ export declare class ChangeLedgerEngine {
19
19
  /** Wait for startup reconciliation and return the number of interrupted journals found. */
20
20
  initialize(): Promise<number>;
21
21
  private initializeStore;
22
+ /**
23
+ * Acquire the exclusive lock for one workspace.
24
+ *
25
+ * A Git worktree also binds the shared per-worktree lock derived from its Git
26
+ * identity; an ordinary directory owns only the durable directory lock.
27
+ */
22
28
  private acquireWorkspace;
23
29
  private reconcileGitCheckpointJournals;
24
30
  private publishGitManifest;
@@ -1,7 +1,7 @@
1
- import type { RepositoryState } from './types.js';
1
+ import type { GitWorkspaceState } from './types.js';
2
2
  /** Repository discovery result plus the eligible path inventory. */
3
3
  export interface RepositorySnapshotSource {
4
- readonly state: RepositoryState;
4
+ readonly state: GitWorkspaceState;
5
5
  readonly paths: readonly string[];
6
6
  }
7
7
  /** Durable identity and shared lock location for one concrete Git worktree. */
@@ -14,6 +14,17 @@ export interface GitWorktreeIdentity {
14
14
  }
15
15
  /** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
16
16
  export declare function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource>;
17
+ /**
18
+ * Discover the owning Git worktree, or return undefined when `cwd` is not
19
+ * inside any repository. Every other discovery failure still throws, so a
20
+ * broken repository never silently degrades into an ordinary directory.
21
+ * @param cwd - candidate working directory.
22
+ * @param signal - optional caller cancellation.
23
+ * @returns the snapshot source, or undefined outside every repository.
24
+ */
25
+ export declare function discoverRepositoryOptional(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource | undefined>;
26
+ /** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
27
+ export declare function discoverRepositoryRootOptional(cwd: string, signal?: AbortSignal): Promise<string | undefined>;
17
28
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
18
29
  export declare function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string>;
19
30
  /** Create or read the per-worktree identity used by refs and cross-store locking. */
@@ -21,7 +32,7 @@ export declare function ensureGitWorktreeIdentity(cwd: string, signal?: AbortSig
21
32
  /** Resolve a moved linked worktree from its common directory and durable identity. */
22
33
  export declare function resolveGitWorktreeRoot(commonDir: string, worktreeId: string, fallback: string, signal?: AbortSignal): Promise<string>;
23
34
  /** Return true when two repository fences refer to the same checkout state. */
24
- export declare function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean;
35
+ export declare function sameRepositoryFence(left: GitWorkspaceState, right: GitWorkspaceState): boolean;
25
36
  /** Run Git with stable non-interactive defaults and return UTF-8 output. */
26
37
  export declare function runGit(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
27
38
  readonly env?: NodeJS.ProcessEnv;
@@ -42,4 +53,4 @@ export declare function runGitBuffer(cwd: string, args: readonly string[], signa
42
53
  readonly maxBuffer?: number;
43
54
  }): Promise<Buffer>;
44
55
  /** Return the Git metadata directory for diagnostics. */
45
- export declare function gitMetadataParent(state: RepositoryState): string;
56
+ export declare function gitMetadataParent(state: GitWorkspaceState): string;
@@ -1,9 +1,9 @@
1
- import { type RepositorySnapshotSource } from './git.js';
2
1
  import type { LedgerStore } from './store.js';
2
+ import { type WorkspaceSnapshotSource } from './workspace.js';
3
3
  import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
4
4
  /** One captured tree, optionally persisted into the blob store. */
5
5
  export interface CapturedTree {
6
- readonly source: RepositorySnapshotSource;
6
+ readonly source: WorkspaceSnapshotSource;
7
7
  readonly entries: Readonly<Record<string, SnapshotEntry>>;
8
8
  readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>;
9
9
  readonly treeHash: string;
@@ -26,7 +26,8 @@ export interface SymlinkSnapshotEntry {
26
26
  /** One path captured by a restore point. */
27
27
  export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry;
28
28
  /** Git facts that fence a restore point to the repository state it observed. */
29
- export interface RepositoryState {
29
+ export interface GitWorkspaceState {
30
+ readonly type: 'git';
30
31
  readonly root: string;
31
32
  readonly commonDir: string;
32
33
  readonly head?: string;
@@ -34,6 +35,15 @@ export interface RepositoryState {
34
35
  readonly operation?: string;
35
36
  readonly stagedPaths: readonly string[];
36
37
  }
38
+ /** Identity of an ordinary directory workspace captured without Git. */
39
+ export interface DirectoryWorkspaceState {
40
+ readonly type: 'directory';
41
+ readonly root: string;
42
+ }
43
+ /** Workspace facts that fence a restore point to the state it observed. */
44
+ export type WorkspaceState = GitWorkspaceState | DirectoryWorkspaceState;
45
+ /** @deprecated Use `WorkspaceState`; the durable manifest field keeps its original name. */
46
+ export type RepositoryState = WorkspaceState;
37
47
  /** Why a restore point exists. */
38
48
  export type RestorePointKind = 'user' | 'rescue' | 'turn';
39
49
  /** Durable workspace restore point. */
@@ -77,8 +87,10 @@ export interface GitCheckpointMetadata {
77
87
  readonly newlyStoredBytes: number;
78
88
  }
79
89
  /** Automatic turn checkpoint whose content is stored in the repository object database. */
80
- export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
90
+ export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash' | 'repository'> {
81
91
  readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION;
92
+ /** Git-native checkpoints only exist for a Git worktree. */
93
+ readonly repository: GitWorkspaceState;
82
94
  /** Deterministic sidecar identity; Git tree integrity is verified separately. */
83
95
  readonly treeHash: string;
84
96
  readonly git: GitCheckpointMetadata;
@@ -110,6 +122,7 @@ export interface RestorePointSummary {
110
122
  readonly id: RestorePointId;
111
123
  readonly kind: RestorePointKind;
112
124
  readonly workspace: string;
125
+ readonly workspaceType: WorkspaceState['type'];
113
126
  readonly sessionId?: string;
114
127
  readonly label?: string;
115
128
  readonly parentRestorePoint?: RestorePointId;