@anionex/dsh-turn-rewind 0.1.1 → 0.2.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/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/lib/git.js
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
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
|
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
7
8
|
/** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
|
|
8
9
|
export async function discoverRepository(cwd, signal) {
|
|
9
10
|
const root = await discoverRepositoryRoot(cwd, signal);
|
|
10
|
-
const sparse = (await
|
|
11
|
+
const sparse = (await runGitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true';
|
|
11
12
|
if (sparse) {
|
|
12
13
|
throw new ChangeLedgerError('SPARSE_CHECKOUT_UNSUPPORTED', 'sparse-checkout worktrees are not supported because absent paths are ambiguous');
|
|
13
14
|
}
|
|
14
|
-
const gitDirRaw = stripLineEnding(await
|
|
15
|
+
const gitDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal));
|
|
15
16
|
const gitDir = await realpath(resolve(root, gitDirRaw));
|
|
16
|
-
const commonDirRaw = stripLineEnding(await
|
|
17
|
+
const commonDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal));
|
|
17
18
|
const commonDir = await realpath(resolve(root, commonDirRaw));
|
|
18
|
-
const stageOutput = await
|
|
19
|
+
const stageOutput = await runGit(root, ['ls-files', '--stage', '-z'], signal);
|
|
19
20
|
const submodules = parseSubmodules(stageOutput);
|
|
20
21
|
if (submodules.length > 0) {
|
|
21
22
|
throw new ChangeLedgerError('SUBMODULE_UNSUPPORTED', `submodules require independent restore points; unsupported gitlinks: ${submodules.slice(0, 10).join(', ')}`);
|
|
22
23
|
}
|
|
23
|
-
const pathOutput = await
|
|
24
|
+
const pathOutput = await runGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal);
|
|
24
25
|
// `ls-files --others` refuses to descend into embedded repositories, such as a
|
|
25
26
|
// linked worktree nested inside this checkout; it reports the directory itself
|
|
26
27
|
// with a trailing slash (for example `nested/`). Those directories are separate
|
|
@@ -29,9 +30,9 @@ export async function discoverRepository(cwd, signal) {
|
|
|
29
30
|
const paths = [...new Set(splitNul(pathOutput)
|
|
30
31
|
.filter((path) => !path.endsWith('/'))
|
|
31
32
|
.map(validateRelativePath))].sort(comparePaths);
|
|
32
|
-
const head = trimOptional(await
|
|
33
|
-
const branch = trimOptional(await
|
|
34
|
-
const stagedOutput = await
|
|
33
|
+
const head = trimOptional(await runGitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal));
|
|
34
|
+
const branch = trimOptional(await runGitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal));
|
|
35
|
+
const stagedOutput = await runGit(root, ['diff', '--cached', '--name-only', '-z'], signal);
|
|
35
36
|
const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths);
|
|
36
37
|
const operation = await gitOperation(gitDir);
|
|
37
38
|
return {
|
|
@@ -49,13 +50,55 @@ export async function discoverRepository(cwd, signal) {
|
|
|
49
50
|
/** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
|
|
50
51
|
export async function discoverRepositoryRoot(cwd, signal) {
|
|
51
52
|
const canonicalCwd = await canonicalDirectory(cwd);
|
|
52
|
-
const rootRaw = await
|
|
53
|
+
const rootRaw = await runGit(canonicalCwd, ['rev-parse', '--show-toplevel'], signal);
|
|
53
54
|
const root = await realpath(stripLineEnding(rootRaw));
|
|
54
55
|
if (!isAbsolute(root)) {
|
|
55
56
|
throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`);
|
|
56
57
|
}
|
|
57
58
|
return root;
|
|
58
59
|
}
|
|
60
|
+
/** Create or read the per-worktree identity used by refs and cross-store locking. */
|
|
61
|
+
export async function ensureGitWorktreeIdentity(cwd, signal) {
|
|
62
|
+
const root = await discoverRepositoryRoot(cwd, signal);
|
|
63
|
+
const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
|
|
64
|
+
const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
|
|
65
|
+
const identityDir = join(gitDir, 'dsh-turn-rewind');
|
|
66
|
+
await mkdir(identityDir, { recursive: true, mode: 0o700 });
|
|
67
|
+
const worktreeId = await ensureWorktreeId(identityDir, commonDir, gitDir);
|
|
68
|
+
return {
|
|
69
|
+
root,
|
|
70
|
+
gitDir,
|
|
71
|
+
commonDir,
|
|
72
|
+
worktreeId,
|
|
73
|
+
lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a moved linked worktree from its common directory and durable identity. */
|
|
77
|
+
export async function resolveGitWorktreeRoot(commonDir, worktreeId, fallback, signal) {
|
|
78
|
+
const candidates = [fallback];
|
|
79
|
+
try {
|
|
80
|
+
const output = await runGit(commonDir, [`--git-dir=${commonDir}`, 'worktree', 'list', '--porcelain', '-z'], signal);
|
|
81
|
+
for (const record of output.split('\0\0')) {
|
|
82
|
+
const first = record.split('\0', 1)[0];
|
|
83
|
+
if (first?.startsWith('worktree '))
|
|
84
|
+
candidates.push(first.slice('worktree '.length));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// The fallback below still gives deterministic failure details if the common directory is gone.
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
91
|
+
try {
|
|
92
|
+
const identity = await readGitWorktreeIdentity(candidate, signal);
|
|
93
|
+
if (identity.worktreeId === worktreeId && identity.commonDir === commonDir)
|
|
94
|
+
return identity.root;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Missing/pruned/unrelated worktrees are not candidates for this durable identity.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new ChangeLedgerError('GIT_WORKTREE_MISSING', `cannot locate Git worktree ${worktreeId} under ${JSON.stringify(commonDir)}`);
|
|
101
|
+
}
|
|
59
102
|
/** Return true when two repository fences refer to the same checkout state. */
|
|
60
103
|
export function sameRepositoryFence(left, right) {
|
|
61
104
|
return left.root === right.root
|
|
@@ -64,20 +107,21 @@ export function sameRepositoryFence(left, right) {
|
|
|
64
107
|
&& left.branch === right.branch
|
|
65
108
|
&& left.operation === right.operation;
|
|
66
109
|
}
|
|
67
|
-
|
|
110
|
+
/** Run Git with stable non-interactive defaults and return UTF-8 output. */
|
|
111
|
+
export async function runGit(cwd, args, signal, options = {}) {
|
|
68
112
|
return new Promise((resolvePromise, reject) => {
|
|
69
113
|
execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
70
114
|
encoding: 'utf8',
|
|
71
|
-
maxBuffer: GIT_MAX_BUFFER,
|
|
115
|
+
maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
|
|
72
116
|
windowsHide: true,
|
|
73
117
|
signal,
|
|
74
|
-
env:
|
|
75
|
-
...process.env,
|
|
76
|
-
GIT_OPTIONAL_LOCKS: '0',
|
|
77
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
78
|
-
},
|
|
118
|
+
env: gitEnvironment(options.env),
|
|
79
119
|
}, (error, stdout, stderr) => {
|
|
80
120
|
if (error !== null) {
|
|
121
|
+
if (signal?.aborted === true) {
|
|
122
|
+
reject(signal.reason ?? error);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
81
125
|
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`, { cause: error }));
|
|
82
126
|
return;
|
|
83
127
|
}
|
|
@@ -85,9 +129,10 @@ async function git(cwd, args, signal) {
|
|
|
85
129
|
});
|
|
86
130
|
});
|
|
87
131
|
}
|
|
88
|
-
|
|
132
|
+
/** Run Git and return undefined for its ordinary "not found" exit status. */
|
|
133
|
+
export async function runGitOptional(cwd, args, signal, options = {}) {
|
|
89
134
|
try {
|
|
90
|
-
return await
|
|
135
|
+
return await runGit(cwd, args, signal, options);
|
|
91
136
|
}
|
|
92
137
|
catch (error) {
|
|
93
138
|
if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1)
|
|
@@ -95,6 +140,105 @@ async function gitOptional(cwd, args, signal) {
|
|
|
95
140
|
throw error;
|
|
96
141
|
}
|
|
97
142
|
}
|
|
143
|
+
/** Run Git with binary stdin and return UTF-8 stdout. */
|
|
144
|
+
export async function runGitInput(cwd, args, input, signal, options = {}) {
|
|
145
|
+
return new Promise((resolvePromise, reject) => {
|
|
146
|
+
const child = spawn('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
signal,
|
|
149
|
+
env: gitEnvironment(options.env),
|
|
150
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
const stdout = [];
|
|
153
|
+
const stderr = [];
|
|
154
|
+
let stdoutBytes = 0;
|
|
155
|
+
child.stdout.on('data', (chunk) => {
|
|
156
|
+
stdoutBytes += chunk.length;
|
|
157
|
+
if (stdoutBytes <= GIT_MAX_BUFFER)
|
|
158
|
+
stdout.push(chunk);
|
|
159
|
+
});
|
|
160
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
161
|
+
let settled = false;
|
|
162
|
+
const fail = (error) => {
|
|
163
|
+
if (settled)
|
|
164
|
+
return;
|
|
165
|
+
settled = true;
|
|
166
|
+
if (signal?.aborted === true) {
|
|
167
|
+
reject(signal.reason ?? error);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${errorMessage(error)}`, { cause: error }));
|
|
171
|
+
};
|
|
172
|
+
child.on('error', fail);
|
|
173
|
+
child.stdin.on('error', fail);
|
|
174
|
+
child.on('close', (code) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
if (code !== 0) {
|
|
178
|
+
settled = true;
|
|
179
|
+
const message = Buffer.concat(stderr).toString('utf8').trim();
|
|
180
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${message || `exit ${String(code)}`}`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (stdoutBytes > GIT_MAX_BUFFER) {
|
|
184
|
+
settled = true;
|
|
185
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} output exceeded ${GIT_MAX_BUFFER} bytes`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
settled = true;
|
|
189
|
+
resolvePromise(Buffer.concat(stdout).toString('utf8'));
|
|
190
|
+
});
|
|
191
|
+
child.stdin.end(input);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** Run Git and return raw stdout bytes. */
|
|
195
|
+
export async function runGitBuffer(cwd, args, signal, options = {}) {
|
|
196
|
+
return new Promise((resolvePromise, reject) => {
|
|
197
|
+
execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
|
|
198
|
+
encoding: 'buffer',
|
|
199
|
+
maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
signal,
|
|
202
|
+
env: gitEnvironment(options.env),
|
|
203
|
+
}, (error, stdout, stderr) => {
|
|
204
|
+
if (error !== null) {
|
|
205
|
+
if (signal?.aborted === true) {
|
|
206
|
+
reject(signal.reason ?? error);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.toString('utf8').trim() || errorMessage(error)}`, { cause: error }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
resolvePromise(stdout);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function gitEnvironment(extra) {
|
|
217
|
+
const env = { ...process.env };
|
|
218
|
+
for (const key of [
|
|
219
|
+
'GIT_DIR',
|
|
220
|
+
'GIT_COMMON_DIR',
|
|
221
|
+
'GIT_WORK_TREE',
|
|
222
|
+
'GIT_INDEX_FILE',
|
|
223
|
+
'GIT_OBJECT_DIRECTORY',
|
|
224
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
225
|
+
'GIT_QUARANTINE_PATH',
|
|
226
|
+
'GIT_REPLACE_REF_BASE',
|
|
227
|
+
'GIT_CONFIG_PARAMETERS',
|
|
228
|
+
'GIT_CONFIG_COUNT',
|
|
229
|
+
])
|
|
230
|
+
delete env[key];
|
|
231
|
+
for (const key of Object.keys(env)) {
|
|
232
|
+
if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
|
|
233
|
+
delete env[key];
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...env,
|
|
237
|
+
...extra,
|
|
238
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
239
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
98
242
|
function gitExitCode(error) {
|
|
99
243
|
if (error === null || typeof error !== 'object' || !('code' in error))
|
|
100
244
|
return undefined;
|
|
@@ -156,6 +300,135 @@ function stripLineEnding(value) {
|
|
|
156
300
|
function comparePaths(left, right) {
|
|
157
301
|
return Buffer.from(left).compare(Buffer.from(right));
|
|
158
302
|
}
|
|
303
|
+
function parseWorktreeId(value) {
|
|
304
|
+
const worktreeId = value.trim();
|
|
305
|
+
if (!/^[0-9a-f]{32}$/.test(worktreeId)) {
|
|
306
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed');
|
|
307
|
+
}
|
|
308
|
+
return worktreeId;
|
|
309
|
+
}
|
|
310
|
+
async function readGitWorktreeIdentity(cwd, signal) {
|
|
311
|
+
const root = await discoverRepositoryRoot(cwd, signal);
|
|
312
|
+
const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
|
|
313
|
+
const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
|
|
314
|
+
const worktreeId = await ensureWorktreeId(join(gitDir, 'dsh-turn-rewind'), commonDir, gitDir);
|
|
315
|
+
return {
|
|
316
|
+
root,
|
|
317
|
+
gitDir,
|
|
318
|
+
commonDir,
|
|
319
|
+
worktreeId,
|
|
320
|
+
lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async function ensureWorktreeId(identityDir, commonDir, gitDir) {
|
|
324
|
+
const identityPath = join(identityDir, 'worktree-id');
|
|
325
|
+
const backupPath = join(identityDir, 'worktree-id.backup');
|
|
326
|
+
const initializedPath = join(identityDir, 'worktree-id.initialized');
|
|
327
|
+
const localPaths = [identityPath, backupPath, initializedPath];
|
|
328
|
+
const centralDir = join(commonDir, 'dsh-turn-rewind-worktree-identities');
|
|
329
|
+
const centralPath = join(centralDir, `${createHash('sha256').update(gitDir).digest('hex')}.id`);
|
|
330
|
+
await mkdir(identityDir, { recursive: true, mode: 0o700 });
|
|
331
|
+
await mkdir(centralDir, { recursive: true, mode: 0o700 });
|
|
332
|
+
const localCopies = await Promise.all(localPaths.map(readIdentityCopy));
|
|
333
|
+
const centralCopy = await readIdentityCopy(centralPath);
|
|
334
|
+
const valid = [...localCopies, centralCopy].flatMap(copy => copy.value === undefined ? [] : [copy.value]);
|
|
335
|
+
if (new Set(valid).size > 1) {
|
|
336
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity copies disagree');
|
|
337
|
+
}
|
|
338
|
+
const localValid = localCopies.flatMap(copy => copy.value === undefined ? [] : [copy.value]);
|
|
339
|
+
const localDurable = localValid[0];
|
|
340
|
+
const durable = localDurable ?? (gitDir === commonDir ? centralCopy.value : undefined);
|
|
341
|
+
if (durable !== undefined) {
|
|
342
|
+
for (const path of [...localPaths, centralPath]) {
|
|
343
|
+
if (await publishIdentityFile(path, durable, true) !== durable) {
|
|
344
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
await syncDirectory(identityDir);
|
|
348
|
+
await syncDirectory(centralDir);
|
|
349
|
+
return durable;
|
|
350
|
+
}
|
|
351
|
+
if (localCopies.some(copy => copy.malformed) || centralCopy.malformed) {
|
|
352
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed and no valid durable copy remains');
|
|
353
|
+
}
|
|
354
|
+
if (gitDir !== commonDir && centralCopy.value !== undefined) {
|
|
355
|
+
throw new ChangeLedgerError('STATE_CORRUPT', 'linked Git worktree identity is ambiguous because only a detached central copy remains');
|
|
356
|
+
}
|
|
357
|
+
const candidate = randomBytes(16).toString('hex');
|
|
358
|
+
const chosen = await publishIdentityFile(identityPath, candidate, false);
|
|
359
|
+
for (const path of [backupPath, initializedPath, centralPath]) {
|
|
360
|
+
if (await publishIdentityFile(path, chosen, true) !== chosen) {
|
|
361
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
await syncDirectory(identityDir);
|
|
365
|
+
await syncDirectory(centralDir);
|
|
366
|
+
return chosen;
|
|
367
|
+
}
|
|
368
|
+
async function readIdentityCopy(path) {
|
|
369
|
+
try {
|
|
370
|
+
return { value: parseWorktreeId(await readFile(path, 'utf8')), malformed: false };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (isNodeError(error, 'ENOENT'))
|
|
374
|
+
return { malformed: false };
|
|
375
|
+
if (error instanceof ChangeLedgerError && error.code === 'STATE_CORRUPT')
|
|
376
|
+
return { malformed: true };
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function publishIdentityFile(path, value, repairMalformed) {
|
|
381
|
+
const directory = dirname(path);
|
|
382
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
383
|
+
const temporary = join(directory, `.${randomBytes(16).toString('hex')}.tmp`);
|
|
384
|
+
try {
|
|
385
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
386
|
+
try {
|
|
387
|
+
await handle.writeFile(`${value}\n`);
|
|
388
|
+
await handle.sync();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await handle.close();
|
|
392
|
+
}
|
|
393
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
394
|
+
try {
|
|
395
|
+
await link(temporary, path);
|
|
396
|
+
await syncDirectory(directory);
|
|
397
|
+
return value;
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (!isNodeError(error, 'EEXIST'))
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
const existing = await readIdentityCopy(path);
|
|
404
|
+
if (existing.value !== undefined)
|
|
405
|
+
return existing.value;
|
|
406
|
+
if (!existing.malformed)
|
|
407
|
+
continue;
|
|
408
|
+
if (!repairMalformed) {
|
|
409
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy is malformed at ${JSON.stringify(path)}`);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
await unlink(path);
|
|
413
|
+
await syncDirectory(directory);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `could not repair Git worktree identity copy at ${JSON.stringify(path)}`);
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
try {
|
|
424
|
+
await unlink(temporary);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (!isNodeError(error, 'ENOENT'))
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
159
432
|
/** Return the Git metadata directory for diagnostics. */
|
|
160
433
|
export function gitMetadataParent(state) {
|
|
161
434
|
return dirname(state.commonDir);
|
package/lib/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ChangeLedgerEngine } from './engine.js';
|
|
2
|
-
import { installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js';
|
|
2
|
+
import { installManageHttp, installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js';
|
|
3
|
+
import { installTurnRewindSettings } from './settings.js';
|
|
3
4
|
export * from './engine.js';
|
|
4
5
|
export * from './errors.js';
|
|
5
6
|
export * from './rewind-host.js';
|
|
7
|
+
export * from './settings.js';
|
|
6
8
|
export * from './types.js';
|
|
7
9
|
/** Cordis service exposed as `ctx.changeLedger` for other DSH plugins. */
|
|
8
10
|
export class ChangeLedgerService {
|
|
@@ -15,10 +17,12 @@ export class ChangeLedgerService {
|
|
|
15
17
|
ctx.inject(['agents'], (scope) => { checkpoints.install(scope); });
|
|
16
18
|
ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope) => {
|
|
17
19
|
installRewindHttp(scope, this.engine, checkpoints);
|
|
20
|
+
installManageHttp(scope, this.engine);
|
|
18
21
|
});
|
|
22
|
+
installTurnRewindSettings(ctx, config, this.engine);
|
|
19
23
|
void this.engine.initialize().then((reconciled) => {
|
|
20
24
|
if (reconciled > 0) {
|
|
21
|
-
ctx.logger.warn(`[change-ledger]
|
|
25
|
+
ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`);
|
|
22
26
|
}
|
|
23
27
|
else {
|
|
24
28
|
ctx.logger.info(`[change-ledger] ready; state=${this.engine.config.storageDir}`);
|
|
@@ -67,5 +71,17 @@ export class ChangeLedgerService {
|
|
|
67
71
|
listRecovery(options) {
|
|
68
72
|
return this.engine.listRecovery(options);
|
|
69
73
|
}
|
|
74
|
+
/** Inventory every workspace this storage root has persisted state for. */
|
|
75
|
+
listWorkspaces(options) {
|
|
76
|
+
return this.engine.listWorkspaces(options);
|
|
77
|
+
}
|
|
78
|
+
/** Delete unprotected restore points recorded for one workspace. */
|
|
79
|
+
purgeWorkspace(options) {
|
|
80
|
+
return this.engine.purgeWorkspace(options);
|
|
81
|
+
}
|
|
82
|
+
/** Swap runtime-tunable configuration; the storage root must stay fixed. */
|
|
83
|
+
updateConfig(config) {
|
|
84
|
+
this.engine.updateConfig(config);
|
|
85
|
+
}
|
|
70
86
|
}
|
|
71
87
|
export default ChangeLedgerService;
|
package/lib/path-utils.js
CHANGED
|
@@ -107,6 +107,13 @@ export async function pathExists(path) {
|
|
|
107
107
|
}
|
|
108
108
|
/** Ensure every existing parent below `root` is a real directory, never a symlink. */
|
|
109
109
|
export async function ensureSafeParents(root, target) {
|
|
110
|
+
await walkSafeParents(root, target, true);
|
|
111
|
+
}
|
|
112
|
+
/** Validate existing parents without creating missing directories. */
|
|
113
|
+
export async function assertSafeParents(root, target) {
|
|
114
|
+
await walkSafeParents(root, target, false);
|
|
115
|
+
}
|
|
116
|
+
async function walkSafeParents(root, target, createMissing) {
|
|
110
117
|
if (!isWithin(root, target) || target === root) {
|
|
111
118
|
throw new ChangeLedgerError('UNSAFE_TARGET', `restore target is outside the workspace: ${JSON.stringify(target)}`);
|
|
112
119
|
}
|
|
@@ -128,6 +135,8 @@ export async function ensureSafeParents(root, target) {
|
|
|
128
135
|
catch (error) {
|
|
129
136
|
if (!isNodeError(error, 'ENOENT'))
|
|
130
137
|
throw error;
|
|
138
|
+
if (!createMissing)
|
|
139
|
+
return;
|
|
131
140
|
await mkdir(current, { mode: 0o755 });
|
|
132
141
|
}
|
|
133
142
|
}
|