@anionex/dsh-turn-rewind 0.1.0 → 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 +303 -23
- 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 +345 -23
- 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,11 +46,18 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
36
46
|
)
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
const pathOutput = await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
const pathOutput = await runGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal)
|
|
50
|
+
// `ls-files --others` refuses to descend into embedded repositories, such as a
|
|
51
|
+
// linked worktree nested inside this checkout; it reports the directory itself
|
|
52
|
+
// with a trailing slash (for example `nested/`). Those directories are separate
|
|
53
|
+
// checkouts, not files of this worktree, so exclude them from the snapshot
|
|
54
|
+
// inventory instead of rejecting the entry as a non-canonical path.
|
|
55
|
+
const paths = [...new Set(splitNul(pathOutput)
|
|
56
|
+
.filter((path) => !path.endsWith('/'))
|
|
57
|
+
.map(validateRelativePath))].sort(comparePaths)
|
|
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)
|
|
44
61
|
const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths)
|
|
45
62
|
const operation = await gitOperation(gitDir)
|
|
46
63
|
|
|
@@ -60,7 +77,7 @@ export async function discoverRepository(cwd: string, signal?: AbortSignal): Pro
|
|
|
60
77
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
61
78
|
export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
62
79
|
const canonicalCwd = await canonicalDirectory(cwd)
|
|
63
|
-
const rootRaw = await
|
|
80
|
+
const rootRaw = await runGit(canonicalCwd, ['rev-parse', '--show-toplevel'], signal)
|
|
64
81
|
const root = await realpath(stripLineEnding(rootRaw))
|
|
65
82
|
if (!isAbsolute(root)) {
|
|
66
83
|
throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`)
|
|
@@ -68,6 +85,51 @@ export async function discoverRepositoryRoot(cwd: string, signal?: AbortSignal):
|
|
|
68
85
|
return root
|
|
69
86
|
}
|
|
70
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
|
+
|
|
71
133
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
72
134
|
export function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean {
|
|
73
135
|
return left.root === right.root
|
|
@@ -77,20 +139,26 @@ export function sameRepositoryFence(left: RepositoryState, right: RepositoryStat
|
|
|
77
139
|
&& left.operation === right.operation
|
|
78
140
|
}
|
|
79
141
|
|
|
80
|
-
|
|
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> {
|
|
81
149
|
return new Promise((resolvePromise, reject) => {
|
|
82
150
|
execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
83
151
|
encoding: 'utf8',
|
|
84
|
-
maxBuffer: GIT_MAX_BUFFER,
|
|
152
|
+
maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
|
|
85
153
|
windowsHide: true,
|
|
86
154
|
signal,
|
|
87
|
-
env:
|
|
88
|
-
...process.env,
|
|
89
|
-
GIT_OPTIONAL_LOCKS: '0',
|
|
90
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
91
|
-
},
|
|
155
|
+
env: gitEnvironment(options.env),
|
|
92
156
|
}, (error, stdout, stderr) => {
|
|
93
157
|
if (error !== null) {
|
|
158
|
+
if (signal?.aborted === true) {
|
|
159
|
+
reject(signal.reason ?? error)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
94
162
|
reject(new ChangeLedgerError(
|
|
95
163
|
'GIT_COMMAND_FAILED',
|
|
96
164
|
`git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`,
|
|
@@ -103,15 +171,140 @@ async function git(cwd: string, args: readonly string[], signal?: AbortSignal):
|
|
|
103
171
|
})
|
|
104
172
|
}
|
|
105
173
|
|
|
106
|
-
|
|
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> {
|
|
107
181
|
try {
|
|
108
|
-
return await
|
|
182
|
+
return await runGit(cwd, args, signal, options)
|
|
109
183
|
} catch (error) {
|
|
110
184
|
if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1) return undefined
|
|
111
185
|
throw error
|
|
112
186
|
}
|
|
113
187
|
}
|
|
114
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
|
+
|
|
115
308
|
function gitExitCode(error: unknown): string | number | undefined {
|
|
116
309
|
if (error === null || typeof error !== 'object' || !('code' in error)) return undefined
|
|
117
310
|
const code = error.code
|
|
@@ -172,6 +365,135 @@ function comparePaths(left: string, right: string): number {
|
|
|
172
365
|
return Buffer.from(left).compare(Buffer.from(right))
|
|
173
366
|
}
|
|
174
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
|
+
|
|
175
497
|
/** Return the Git metadata directory for diagnostics. */
|
|
176
498
|
export function gitMetadataParent(state: RepositoryState): string {
|
|
177
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
|
}
|