@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/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/lib/client.js +3 -1
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +482 -118
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +1 -1
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +119 -29
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1281 -106
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +27 -0
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +3 -1
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +70 -6
- package/lib/types/types.d.ts +44 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +3 -1
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +3 -0
- package/src/deadline.ts +18 -0
- package/src/engine.ts +542 -124
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +1 -1
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +134 -30
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1403 -91
- package/src/types.ts +48 -1
- package/src/validate.ts +67 -7
package/src/git.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
49
|
-
const branch = trimOptional(await
|
|
50
|
-
const stagedOutput = await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
}
|