@anionex/dsh-turn-rewind 0.1.1 → 0.1.2

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/git.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { execFile } from 'node:child_process'
2
- import { lstat, realpath } from 'node:fs/promises'
3
- import { dirname, isAbsolute, resolve } from 'node:path'
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises'
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
4
5
  import { ChangeLedgerError, errorMessage } from './errors.js'
5
- import { canonicalDirectory, isNodeError, validateRelativePath } from './path-utils.js'
6
+ import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js'
6
7
  import type { RepositoryState } from './types.js'
7
8
 
8
9
  const GIT_MAX_BUFFER = 32 * 1024 * 1024
@@ -13,21 +14,30 @@ export interface RepositorySnapshotSource {
13
14
  readonly paths: readonly string[]
14
15
  }
15
16
 
17
+ /** Durable identity and shared lock location for one concrete Git worktree. */
18
+ export interface GitWorktreeIdentity {
19
+ readonly root: string
20
+ readonly gitDir: string
21
+ readonly commonDir: string
22
+ readonly worktreeId: string
23
+ readonly lockPath: string
24
+ }
25
+
16
26
  /** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
17
27
  export async function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource> {
18
28
  const root = await discoverRepositoryRoot(cwd, signal)
19
29
 
20
- const sparse = (await gitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true'
30
+ const sparse = (await runGitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true'
21
31
  if (sparse) {
22
32
  throw new ChangeLedgerError('SPARSE_CHECKOUT_UNSUPPORTED', 'sparse-checkout worktrees are not supported because absent paths are ambiguous')
23
33
  }
24
34
 
25
- const gitDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-dir'], signal))
35
+ const gitDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))
26
36
  const gitDir = await realpath(resolve(root, gitDirRaw))
27
- const commonDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-common-dir'], signal))
37
+ const commonDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))
28
38
  const commonDir = await realpath(resolve(root, commonDirRaw))
29
39
 
30
- const stageOutput = await git(root, ['ls-files', '--stage', '-z'], signal)
40
+ const stageOutput = await runGit(root, ['ls-files', '--stage', '-z'], signal)
31
41
  const submodules = parseSubmodules(stageOutput)
32
42
  if (submodules.length > 0) {
33
43
  throw new ChangeLedgerError(
@@ -36,7 +46,7 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
36
46
  )
37
47
  }
38
48
 
39
- const pathOutput = await git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal)
49
+ const pathOutput = await runGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal)
40
50
  // `ls-files --others` refuses to descend into embedded repositories, such as a
41
51
  // linked worktree nested inside this checkout; it reports the directory itself
42
52
  // with a trailing slash (for example `nested/`). Those directories are separate
@@ -45,9 +55,9 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
45
55
  const paths = [...new Set(splitNul(pathOutput)
46
56
  .filter((path) => !path.endsWith('/'))
47
57
  .map(validateRelativePath))].sort(comparePaths)
48
- const head = trimOptional(await gitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal))
49
- const branch = trimOptional(await gitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal))
50
- const stagedOutput = await git(root, ['diff', '--cached', '--name-only', '-z'], signal)
58
+ const head = trimOptional(await runGitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal))
59
+ const branch = trimOptional(await runGitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal))
60
+ const stagedOutput = await runGit(root, ['diff', '--cached', '--name-only', '-z'], signal)
51
61
  const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths)
52
62
  const operation = await gitOperation(gitDir)
53
63
 
@@ -67,7 +77,7 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
67
77
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
68
78
  export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
69
79
  const canonicalCwd = await canonicalDirectory(cwd)
70
- const rootRaw = await git(canonicalCwd, ['rev-parse', '--show-toplevel'], signal)
80
+ const rootRaw = await runGit(canonicalCwd, ['rev-parse', '--show-toplevel'], signal)
71
81
  const root = await realpath(stripLineEnding(rootRaw))
72
82
  if (!isAbsolute(root)) {
73
83
  throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`)
@@ -75,6 +85,51 @@ export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal):
75
85
  return root
76
86
  }
77
87
 
88
+ /** Create or read the per-worktree identity used by refs and cross-store locking. */
89
+ export async function ensureGitWorktreeIdentity(cwd: string, signal?: AbortSignal): Promise<GitWorktreeIdentity> {
90
+ const root = await discoverRepositoryRoot(cwd, signal)
91
+ const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))))
92
+ const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))))
93
+ const identityDir = join(gitDir, 'dsh-turn-rewind')
94
+ await mkdir(identityDir, { recursive: true, mode: 0o700 })
95
+ const worktreeId = await ensureWorktreeId(identityDir, commonDir, gitDir)
96
+ return {
97
+ root,
98
+ gitDir,
99
+ commonDir,
100
+ worktreeId,
101
+ lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
102
+ }
103
+ }
104
+
105
+ /** Resolve a moved linked worktree from its common directory and durable identity. */
106
+ export async function resolveGitWorktreeRoot(
107
+ commonDir: string,
108
+ worktreeId: string,
109
+ fallback: string,
110
+ signal?: AbortSignal,
111
+ ): Promise<string> {
112
+ const candidates = [fallback]
113
+ try {
114
+ const output = await runGit(commonDir, [`--git-dir=${commonDir}`, 'worktree', 'list', '--porcelain', '-z'], signal)
115
+ for (const record of output.split('\0\0')) {
116
+ const first = record.split('\0', 1)[0]
117
+ if (first?.startsWith('worktree ')) candidates.push(first.slice('worktree '.length))
118
+ }
119
+ } catch {
120
+ // The fallback below still gives deterministic failure details if the common directory is gone.
121
+ }
122
+ for (const candidate of [...new Set(candidates)]) {
123
+ try {
124
+ const identity = await readGitWorktreeIdentity(candidate, signal)
125
+ if (identity.worktreeId === worktreeId && identity.commonDir === commonDir) return identity.root
126
+ } catch {
127
+ // Missing/pruned/unrelated worktrees are not candidates for this durable identity.
128
+ }
129
+ }
130
+ throw new ChangeLedgerError('GIT_WORKTREE_MISSING', `cannot locate Git worktree ${worktreeId} under ${JSON.stringify(commonDir)}`)
131
+ }
132
+
78
133
  /** Return true when two repository fences refer to the same checkout state. */
79
134
  export function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean {
80
135
  return left.root === right.root
@@ -84,20 +139,26 @@ export function sameRepositoryFence(left: RepositoryState, right: RepositoryStat
84
139
  && left.operation === right.operation
85
140
  }
86
141
 
87
- async function git(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
142
+ /** Run Git with stable non-interactive defaults and return UTF-8 output. */
143
+ export async function runGit(
144
+ cwd: string,
145
+ args: readonly string[],
146
+ signal?: AbortSignal,
147
+ options: { readonly env?: NodeJS.ProcessEnv; readonly maxBuffer?: number } = {},
148
+ ): Promise<string> {
88
149
  return new Promise((resolvePromise, reject) => {
89
150
  execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
90
151
  encoding: 'utf8',
91
- maxBuffer: GIT_MAX_BUFFER,
152
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
92
153
  windowsHide: true,
93
154
  signal,
94
- env: {
95
- ...process.env,
96
- GIT_OPTIONAL_LOCKS: '0',
97
- GIT_TERMINAL_PROMPT: '0',
98
- },
155
+ env: gitEnvironment(options.env),
99
156
  }, (error, stdout, stderr) => {
100
157
  if (error !== null) {
158
+ if (signal?.aborted === true) {
159
+ reject(signal.reason ?? error)
160
+ return
161
+ }
101
162
  reject(new ChangeLedgerError(
102
163
  'GIT_COMMAND_FAILED',
103
164
  `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`,
@@ -110,15 +171,140 @@ async function git(cwd: string, args: readonly string[], signal?: AbortSignal):
110
171
  })
111
172
  }
112
173
 
113
- async function gitOptional(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string | undefined> {
174
+ /** Run Git and return undefined for its ordinary "not found" exit status. */
175
+ export async function runGitOptional(
176
+ cwd: string,
177
+ args: readonly string[],
178
+ signal?: AbortSignal,
179
+ options: { readonly env?: NodeJS.ProcessEnv; readonly maxBuffer?: number } = {},
180
+ ): Promise<string | undefined> {
114
181
  try {
115
- return await git(cwd, args, signal)
182
+ return await runGit(cwd, args, signal, options)
116
183
  } catch (error) {
117
184
  if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1) return undefined
118
185
  throw error
119
186
  }
120
187
  }
121
188
 
189
+ /** Run Git with binary stdin and return UTF-8 stdout. */
190
+ export async function runGitInput(
191
+ cwd: string,
192
+ args: readonly string[],
193
+ input: Buffer | string,
194
+ signal?: AbortSignal,
195
+ options: { readonly env?: NodeJS.ProcessEnv } = {},
196
+ ): Promise<string> {
197
+ return new Promise((resolvePromise, reject) => {
198
+ const child = spawn('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
199
+ windowsHide: true,
200
+ signal,
201
+ env: gitEnvironment(options.env),
202
+ stdio: ['pipe', 'pipe', 'pipe'],
203
+ })
204
+ const stdout: Buffer[] = []
205
+ const stderr: Buffer[] = []
206
+ let stdoutBytes = 0
207
+ child.stdout.on('data', (chunk: Buffer) => {
208
+ stdoutBytes += chunk.length
209
+ if (stdoutBytes <= GIT_MAX_BUFFER) stdout.push(chunk)
210
+ })
211
+ child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
212
+ let settled = false
213
+ const fail = (error: unknown) => {
214
+ if (settled) return
215
+ settled = true
216
+ if (signal?.aborted === true) {
217
+ reject(signal.reason ?? error)
218
+ return
219
+ }
220
+ reject(new ChangeLedgerError(
221
+ 'GIT_COMMAND_FAILED',
222
+ `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${errorMessage(error)}`,
223
+ { cause: error },
224
+ ))
225
+ }
226
+ child.on('error', fail)
227
+ child.stdin.on('error', fail)
228
+ child.on('close', (code) => {
229
+ if (settled) return
230
+ if (code !== 0) {
231
+ settled = true
232
+ const message = Buffer.concat(stderr).toString('utf8').trim()
233
+ reject(new ChangeLedgerError(
234
+ 'GIT_COMMAND_FAILED',
235
+ `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${message || `exit ${String(code)}`}`,
236
+ ))
237
+ return
238
+ }
239
+ if (stdoutBytes > GIT_MAX_BUFFER) {
240
+ settled = true
241
+ reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} output exceeded ${GIT_MAX_BUFFER} bytes`))
242
+ return
243
+ }
244
+ settled = true
245
+ resolvePromise(Buffer.concat(stdout).toString('utf8'))
246
+ })
247
+ child.stdin.end(input)
248
+ })
249
+ }
250
+
251
+ /** Run Git and return raw stdout bytes. */
252
+ export async function runGitBuffer(
253
+ cwd: string,
254
+ args: readonly string[],
255
+ signal?: AbortSignal,
256
+ options: { readonly env?: NodeJS.ProcessEnv; readonly maxBuffer?: number } = {},
257
+ ): Promise<Buffer> {
258
+ return new Promise((resolvePromise, reject) => {
259
+ execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
260
+ encoding: 'buffer',
261
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
262
+ windowsHide: true,
263
+ signal,
264
+ env: gitEnvironment(options.env),
265
+ }, (error, stdout, stderr) => {
266
+ if (error !== null) {
267
+ if (signal?.aborted === true) {
268
+ reject(signal.reason ?? error)
269
+ return
270
+ }
271
+ reject(new ChangeLedgerError(
272
+ 'GIT_COMMAND_FAILED',
273
+ `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.toString('utf8').trim() || errorMessage(error)}`,
274
+ { cause: error },
275
+ ))
276
+ return
277
+ }
278
+ resolvePromise(stdout)
279
+ })
280
+ })
281
+ }
282
+
283
+ function gitEnvironment(extra: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
284
+ const env: NodeJS.ProcessEnv = { ...process.env }
285
+ for (const key of [
286
+ 'GIT_DIR',
287
+ 'GIT_COMMON_DIR',
288
+ 'GIT_WORK_TREE',
289
+ 'GIT_INDEX_FILE',
290
+ 'GIT_OBJECT_DIRECTORY',
291
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
292
+ 'GIT_QUARANTINE_PATH',
293
+ 'GIT_REPLACE_REF_BASE',
294
+ 'GIT_CONFIG_PARAMETERS',
295
+ 'GIT_CONFIG_COUNT',
296
+ ]) delete env[key]
297
+ for (const key of Object.keys(env)) {
298
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) delete env[key]
299
+ }
300
+ return {
301
+ ...env,
302
+ ...extra,
303
+ GIT_OPTIONAL_LOCKS: '0',
304
+ GIT_TERMINAL_PROMPT: '0',
305
+ }
306
+ }
307
+
122
308
  function gitExitCode(error: unknown): string | number | undefined {
123
309
  if (error === null || typeof error !== 'object' || !('code' in error)) return undefined
124
310
  const code = error.code
@@ -179,6 +365,135 @@ function comparePaths(left: string, right: string): number {
179
365
  return Buffer.from(left).compare(Buffer.from(right))
180
366
  }
181
367
 
368
+ function parseWorktreeId(value: string): string {
369
+ const worktreeId = value.trim()
370
+ if (!/^[0-9a-f]{32}$/.test(worktreeId)) {
371
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed')
372
+ }
373
+ return worktreeId
374
+ }
375
+
376
+ async function readGitWorktreeIdentity(cwd: string, signal?: AbortSignal): Promise<GitWorktreeIdentity> {
377
+ const root = await discoverRepositoryRoot(cwd, signal)
378
+ const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))))
379
+ const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))))
380
+ const worktreeId = await ensureWorktreeId(join(gitDir, 'dsh-turn-rewind'), commonDir, gitDir)
381
+ return {
382
+ root,
383
+ gitDir,
384
+ commonDir,
385
+ worktreeId,
386
+ lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
387
+ }
388
+ }
389
+
390
+ async function ensureWorktreeId(identityDir: string, commonDir: string, gitDir: string): Promise<string> {
391
+ const identityPath = join(identityDir, 'worktree-id')
392
+ const backupPath = join(identityDir, 'worktree-id.backup')
393
+ const initializedPath = join(identityDir, 'worktree-id.initialized')
394
+ const localPaths = [identityPath, backupPath, initializedPath]
395
+ const centralDir = join(commonDir, 'dsh-turn-rewind-worktree-identities')
396
+ const centralPath = join(centralDir, `${createHash('sha256').update(gitDir).digest('hex')}.id`)
397
+ await mkdir(identityDir, { recursive: true, mode: 0o700 })
398
+ await mkdir(centralDir, { recursive: true, mode: 0o700 })
399
+ const localCopies = await Promise.all(localPaths.map(readIdentityCopy))
400
+ const centralCopy = await readIdentityCopy(centralPath)
401
+ const valid = [...localCopies, centralCopy].flatMap(copy => copy.value === undefined ? [] : [copy.value])
402
+ if (new Set(valid).size > 1) {
403
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity copies disagree')
404
+ }
405
+ const localValid = localCopies.flatMap(copy => copy.value === undefined ? [] : [copy.value])
406
+ const localDurable = localValid[0]
407
+ const durable = localDurable ?? (gitDir === commonDir ? centralCopy.value : undefined)
408
+ if (durable !== undefined) {
409
+ for (const path of [...localPaths, centralPath]) {
410
+ if (await publishIdentityFile(path, durable, true) !== durable) {
411
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`)
412
+ }
413
+ }
414
+ await syncDirectory(identityDir)
415
+ await syncDirectory(centralDir)
416
+ return durable
417
+ }
418
+ if (localCopies.some(copy => copy.malformed) || centralCopy.malformed) {
419
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed and no valid durable copy remains')
420
+ }
421
+ if (gitDir !== commonDir && centralCopy.value !== undefined) {
422
+ throw new ChangeLedgerError(
423
+ 'STATE_CORRUPT',
424
+ 'linked Git worktree identity is ambiguous because only a detached central copy remains',
425
+ )
426
+ }
427
+ const candidate = randomBytes(16).toString('hex')
428
+ const chosen = await publishIdentityFile(identityPath, candidate, false)
429
+ for (const path of [backupPath, initializedPath, centralPath]) {
430
+ if (await publishIdentityFile(path, chosen, true) !== chosen) {
431
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`)
432
+ }
433
+ }
434
+ await syncDirectory(identityDir)
435
+ await syncDirectory(centralDir)
436
+ return chosen
437
+ }
438
+
439
+ interface IdentityCopy {
440
+ readonly value?: string
441
+ readonly malformed: boolean
442
+ }
443
+
444
+ async function readIdentityCopy(path: string): Promise<IdentityCopy> {
445
+ try {
446
+ return { value: parseWorktreeId(await readFile(path, 'utf8')), malformed: false }
447
+ } catch (error) {
448
+ if (isNodeError(error, 'ENOENT')) return { malformed: false }
449
+ if (error instanceof ChangeLedgerError && error.code === 'STATE_CORRUPT') return { malformed: true }
450
+ throw error
451
+ }
452
+ }
453
+
454
+ async function publishIdentityFile(path: string, value: string, repairMalformed: boolean): Promise<string> {
455
+ const directory = dirname(path)
456
+ await mkdir(directory, { recursive: true, mode: 0o700 })
457
+ const temporary = join(directory, `.${randomBytes(16).toString('hex')}.tmp`)
458
+ try {
459
+ const handle = await open(temporary, 'wx', 0o600)
460
+ try {
461
+ await handle.writeFile(`${value}\n`)
462
+ await handle.sync()
463
+ } finally {
464
+ await handle.close()
465
+ }
466
+ for (let attempt = 0; attempt < 8; attempt += 1) {
467
+ try {
468
+ await link(temporary, path)
469
+ await syncDirectory(directory)
470
+ return value
471
+ } catch (error) {
472
+ if (!isNodeError(error, 'EEXIST')) throw error
473
+ }
474
+ const existing = await readIdentityCopy(path)
475
+ if (existing.value !== undefined) return existing.value
476
+ if (!existing.malformed) continue
477
+ if (!repairMalformed) {
478
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy is malformed at ${JSON.stringify(path)}`)
479
+ }
480
+ try {
481
+ await unlink(path)
482
+ await syncDirectory(directory)
483
+ } catch (error) {
484
+ if (!isNodeError(error, 'ENOENT')) throw error
485
+ }
486
+ }
487
+ throw new ChangeLedgerError('STATE_CORRUPT', `could not repair Git worktree identity copy at ${JSON.stringify(path)}`)
488
+ } finally {
489
+ try {
490
+ await unlink(temporary)
491
+ } catch (error) {
492
+ if (!isNodeError(error, 'ENOENT')) throw error
493
+ }
494
+ }
495
+ }
496
+
182
497
  /** Return the Git metadata directory for diagnostics. */
183
498
  export function gitMetadataParent(state: RepositoryState): string {
184
499
  return dirname(state.commonDir)
package/src/index.ts CHANGED
@@ -33,7 +33,7 @@ export class ChangeLedgerService {
33
33
  })
34
34
  void this.engine.initialize().then((reconciled) => {
35
35
  if (reconciled > 0) {
36
- ctx.logger.warn(`[change-ledger] marked ${reconciled} interrupted restore operation(s) for manual recovery`)
36
+ ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`)
37
37
  } else {
38
38
  ctx.logger.info(`[change-ledger] ready; state=${this.engine.config.storageDir}`)
39
39
  }
package/src/path-utils.ts CHANGED
@@ -107,6 +107,15 @@ export async function pathExists(path: string): Promise<boolean> {
107
107
 
108
108
  /** Ensure every existing parent below `root` is a real directory, never a symlink. */
109
109
  export async function ensureSafeParents(root: string, target: string): Promise<void> {
110
+ await walkSafeParents(root, target, true)
111
+ }
112
+
113
+ /** Validate existing parents without creating missing directories. */
114
+ export async function assertSafeParents(root: string, target: string): Promise<void> {
115
+ await walkSafeParents(root, target, false)
116
+ }
117
+
118
+ async function walkSafeParents(root: string, target: string, createMissing: boolean): Promise<void> {
110
119
  if (!isWithin(root, target) || target === root) {
111
120
  throw new ChangeLedgerError('UNSAFE_TARGET', `restore target is outside the workspace: ${JSON.stringify(target)}`)
112
121
  }
@@ -125,6 +134,7 @@ export async function ensureSafeParents(root: string, target: string): Promise<v
125
134
  }
126
135
  } catch (error) {
127
136
  if (!isNodeError(error, 'ENOENT')) throw error
137
+ if (!createMissing) return
128
138
  await mkdir(current, { mode: 0o755 })
129
139
  }
130
140
  }