amalgm 0.1.245 → 0.1.246
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 +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -7,47 +7,23 @@
|
|
|
7
7
|
* no shell, pinned cwd, no prompts, and config armor so repo-configured
|
|
8
8
|
* programs (hooks, fsmonitor) can never run under an executed transition.
|
|
9
9
|
*
|
|
10
|
-
* The verbs are deliberately few and exact —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* journal before control returns.
|
|
10
|
+
* The verbs are deliberately few and exact — fetch one branch from a
|
|
11
|
+
* named remote, fast-forward to an exact commit, apply or reverse a
|
|
12
|
+
* captured patch. Anything messier is a human act on one machine; the
|
|
13
|
+
* rail carries its settled outcome. Every execution, success or failure,
|
|
14
|
+
* lands as one line in a visible journal before control returns.
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
17
|
const fs = require('fs');
|
|
19
18
|
const path = require('path');
|
|
20
|
-
const
|
|
21
|
-
const { execFile, execFileSync } = require('child_process');
|
|
19
|
+
const { execFile } = require('child_process');
|
|
22
20
|
const { promisify } = require('util');
|
|
23
21
|
const { AMALGM_DIR } = require('../config');
|
|
24
|
-
const durable = require('./durable');
|
|
25
|
-
const nativeFileTransaction = require('./native-file-transaction/client');
|
|
26
22
|
|
|
27
23
|
const execFileAsync = promisify(execFile);
|
|
28
24
|
|
|
29
25
|
const JOURNAL_FILE = path.join(AMALGM_DIR, 'state', 'workspace-transitions.jsonl');
|
|
30
|
-
const
|
|
31
|
-
const GIT_METADATA_TRANSACTION_CONTRACT = 'workspace-git-metadata-transaction@1';
|
|
32
|
-
const GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT = 'workspace-git-worktree-namespace-guard@2';
|
|
33
|
-
const APPLY_RECEIPT_DIRECTORY = /^\.amalgm-apply-[0-9a-f]{64}$/;
|
|
34
|
-
const APPLY_RECEIPT_EXCLUDE = `.amalgm-apply-${'[0-9a-f]'.repeat(64)}/`;
|
|
35
|
-
|
|
36
|
-
function processLifetime(pid) {
|
|
37
|
-
return nativeFileTransaction.processLifetime(pid);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const PROCESS_LIFETIME = processLifetime(process.pid);
|
|
41
|
-
if (!PROCESS_LIFETIME?.live) {
|
|
42
|
-
throw new Error('workspace transitions cannot identify their process lifetime');
|
|
43
|
-
}
|
|
44
|
-
const PROCESS_START_IDENTITY = PROCESS_LIFETIME.identity;
|
|
45
|
-
|
|
46
|
-
function guardOwnerIsActive(transaction) {
|
|
47
|
-
const owner = processLifetime(transaction.ownerPid);
|
|
48
|
-
if (owner === null) return true;
|
|
49
|
-
return owner.live && owner.identity === transaction.ownerStarted;
|
|
50
|
-
}
|
|
26
|
+
const HYDRATION_DIR = path.join(AMALGM_DIR, 'state', 'repo-hydration');
|
|
51
27
|
|
|
52
28
|
const TRANSITION_ENV = {
|
|
53
29
|
GIT_TERMINAL_PROMPT: '0',
|
|
@@ -78,43 +54,14 @@ function journal(entry) {
|
|
|
78
54
|
}
|
|
79
55
|
}
|
|
80
56
|
|
|
81
|
-
function
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
const child = execFile(file, args, options, (error, stdout, stderr) => {
|
|
84
|
-
if (error) {
|
|
85
|
-
error.stdout = stdout;
|
|
86
|
-
error.stderr = stderr;
|
|
87
|
-
reject(error);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
resolve({ stdout, stderr });
|
|
91
|
-
});
|
|
92
|
-
child.stdin.on('error', () => {});
|
|
93
|
-
child.stdin.end(input);
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function run(
|
|
98
|
-
action,
|
|
99
|
-
worktreeRoot,
|
|
100
|
-
args,
|
|
101
|
-
{
|
|
102
|
-
timeoutMs = 120_000,
|
|
103
|
-
journalResult = true,
|
|
104
|
-
input = null,
|
|
105
|
-
env = {},
|
|
106
|
-
} = {},
|
|
107
|
-
) {
|
|
57
|
+
async function run(action, worktreeRoot, args, { timeoutMs = 120_000, journalResult = true } = {}) {
|
|
108
58
|
try {
|
|
109
|
-
const
|
|
59
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
110
60
|
cwd: worktreeRoot,
|
|
111
|
-
env: { ...process.env, ...TRANSITION_ENV
|
|
61
|
+
env: { ...process.env, ...TRANSITION_ENV },
|
|
112
62
|
timeout: timeoutMs,
|
|
113
63
|
maxBuffer: 32 * 1024 * 1024,
|
|
114
|
-
};
|
|
115
|
-
const { stdout } = input === null
|
|
116
|
-
? await execFileAsync('git', args, options)
|
|
117
|
-
: await execFileWithInput('git', args, options, input);
|
|
64
|
+
});
|
|
118
65
|
if (journalResult) journal({ action, worktree: worktreeRoot, args, ok: true });
|
|
119
66
|
return stdout;
|
|
120
67
|
} catch (error) {
|
|
@@ -126,6 +73,14 @@ async function run(
|
|
|
126
73
|
}
|
|
127
74
|
}
|
|
128
75
|
|
|
76
|
+
/** Bring one branch's objects and remote-tracking ref in from a named remote. */
|
|
77
|
+
function fetchBranch(worktreeRoot, remote, branch) {
|
|
78
|
+
return run('fetch-branch', worktreeRoot, [
|
|
79
|
+
'-C', worktreeRoot, 'fetch', '--no-tags', remote,
|
|
80
|
+
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
129
84
|
async function createBundle(worktreeRoot, oid, bundleFile, exportRef) {
|
|
130
85
|
await run('prepare-bundle-ref', worktreeRoot, [
|
|
131
86
|
'-C', worktreeRoot, 'update-ref', exportRef, oid,
|
|
@@ -173,18 +128,55 @@ async function initializeRepo(worktreeRoot, remote) {
|
|
|
173
128
|
}
|
|
174
129
|
}
|
|
175
130
|
|
|
176
|
-
async function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
131
|
+
async function checkoutInitialCard(worktreeRoot, { branch, oid }) {
|
|
132
|
+
await run('checkout-initial-card', worktreeRoot, branch
|
|
133
|
+
? ['-C', worktreeRoot, 'checkout', '-B', branch, oid]
|
|
134
|
+
: ['-C', worktreeRoot, 'checkout', '--detach', oid], {
|
|
135
|
+
timeoutMs: 60_000,
|
|
136
|
+
});
|
|
137
|
+
const after = await currentHead(worktreeRoot);
|
|
138
|
+
if (after.branch !== (branch || null) || after.oid !== oid) {
|
|
139
|
+
throw new TransitionError(
|
|
140
|
+
'checkout-initial-card',
|
|
181
141
|
worktreeRoot,
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
142
|
+
`expected ${branch || '(detached)'}@${oid}, observed ${after.branch || '(detached)'}@${after.oid}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function holdInitialFiles(worktreeRoot) {
|
|
148
|
+
const entries = fs.readdirSync(worktreeRoot).filter((entry) => entry !== '.git');
|
|
149
|
+
if (!entries.length) return null;
|
|
150
|
+
fs.mkdirSync(HYDRATION_DIR, { recursive: true });
|
|
151
|
+
const holding = fs.mkdtempSync(path.join(HYDRATION_DIR, 'hold-'));
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
fs.renameSync(path.join(worktreeRoot, entry), path.join(holding, entry));
|
|
187
154
|
}
|
|
155
|
+
journal({ action: 'hold-initial-files', worktree: worktreeRoot, holding, entries, ok: true });
|
|
156
|
+
return holding;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function restoreInitialFiles(worktreeRoot, holding) {
|
|
160
|
+
if (!holding) return;
|
|
161
|
+
fs.cpSync(holding, worktreeRoot, { recursive: true, force: true, dereference: false });
|
|
162
|
+
fs.rmSync(holding, { recursive: true, force: true });
|
|
163
|
+
journal({ action: 'restore-initial-files', worktree: worktreeRoot, holding, ok: true });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Fast-forward the current branch to an exact commit. Refuses anything but a ff. */
|
|
167
|
+
function fastForwardTo(worktreeRoot, oid) {
|
|
168
|
+
return run('fast-forward', worktreeRoot, ['-C', worktreeRoot, 'merge', '--ff-only', oid], {
|
|
169
|
+
timeoutMs: 60_000,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function currentHead(worktreeRoot) {
|
|
174
|
+
const oid = (await run(
|
|
175
|
+
'observe-head',
|
|
176
|
+
worktreeRoot,
|
|
177
|
+
['-C', worktreeRoot, 'rev-parse', '--verify', 'HEAD'],
|
|
178
|
+
{ journalResult: false },
|
|
179
|
+
)).trim();
|
|
188
180
|
let branch = null;
|
|
189
181
|
try {
|
|
190
182
|
branch = (await run(
|
|
@@ -199,1745 +191,211 @@ async function currentHead(worktreeRoot) {
|
|
|
199
191
|
return { branch, oid };
|
|
200
192
|
}
|
|
201
193
|
|
|
202
|
-
async function branchOid(worktreeRoot, branch) {
|
|
203
|
-
try {
|
|
204
|
-
return (await run(
|
|
205
|
-
'observe-branch-oid',
|
|
206
|
-
worktreeRoot,
|
|
207
|
-
['-C', worktreeRoot, 'rev-parse', '--verify', `refs/heads/${branch}`],
|
|
208
|
-
{ journalResult: false },
|
|
209
|
-
)).trim();
|
|
210
|
-
} catch (error) {
|
|
211
|
-
if (error instanceof TransitionError) return null;
|
|
212
|
-
throw error;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function syncDirectory(directory) {
|
|
217
|
-
const descriptor = fs.openSync(directory, 'r');
|
|
218
|
-
try {
|
|
219
|
-
fs.fsyncSync(descriptor);
|
|
220
|
-
} finally {
|
|
221
|
-
fs.closeSync(descriptor);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
194
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* the remaining content-addressed steps.
|
|
195
|
+
* Materialize one exact card. Branch and OID are one instruction:
|
|
196
|
+
* either the expected old pair still exists and the target pair lands,
|
|
197
|
+
* or the command refuses without moving another branch.
|
|
230
198
|
*/
|
|
231
|
-
async function
|
|
199
|
+
async function moveToCard(worktreeRoot, {
|
|
232
200
|
expectedBranch,
|
|
233
201
|
expectedOid,
|
|
234
202
|
targetBranch,
|
|
235
203
|
targetOid,
|
|
236
|
-
operation,
|
|
237
|
-
}) {
|
|
238
|
-
if (!/^[0-9a-f]{64}$/.test(operation || '')) {
|
|
239
|
-
throw new Error('HEAD movement has no durable operation identity');
|
|
240
|
-
}
|
|
241
|
-
const expected = Buffer.from(expectedBranch
|
|
242
|
-
? `ref: refs/heads/${expectedBranch}\n`
|
|
243
|
-
: `${expectedOid}\n`);
|
|
244
|
-
const target = Buffer.from(targetBranch
|
|
245
|
-
? `ref: refs/heads/${targetBranch}\n`
|
|
246
|
-
: `${targetOid}\n`);
|
|
247
|
-
if (expected.equals(target)) return;
|
|
248
|
-
applyGitMetadataTransaction(
|
|
249
|
-
worktreeRoot,
|
|
250
|
-
'HEAD',
|
|
251
|
-
expected,
|
|
252
|
-
target,
|
|
253
|
-
operation,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Move only repository metadata to an exact card.
|
|
259
|
-
*
|
|
260
|
-
* Branch refs use compare-and-swap. Switching attachment uses Git's HEAD
|
|
261
|
-
* lock. Neither operation touches the index or working tree, so replay after
|
|
262
|
-
* a crash is idempotent and cannot erase pending local work.
|
|
263
|
-
*/
|
|
264
|
-
async function moveHeadToCard(worktreeRoot, {
|
|
265
|
-
expectedBranch,
|
|
266
|
-
expectedOid,
|
|
267
|
-
expectedTargetOid,
|
|
268
|
-
targetBranch,
|
|
269
|
-
targetOid,
|
|
270
|
-
operation,
|
|
271
204
|
}) {
|
|
272
205
|
const before = await currentHead(worktreeRoot);
|
|
273
|
-
if (before.branch === (targetBranch || null) && before.oid === targetOid) {
|
|
274
|
-
if (
|
|
275
|
-
expectedBranch === (targetBranch || null)
|
|
276
|
-
&& expectedOid === targetOid
|
|
277
|
-
) return;
|
|
278
|
-
if (
|
|
279
|
-
/^[0-9a-f]{64}$/.test(operation || '')
|
|
280
|
-
&& (
|
|
281
|
-
(
|
|
282
|
-
expectedBranch === (targetBranch || null)
|
|
283
|
-
&& await branchReceiptCrossed(worktreeRoot, operation, targetBranch)
|
|
284
|
-
)
|
|
285
|
-
|| (
|
|
286
|
-
expectedBranch !== (targetBranch || null)
|
|
287
|
-
&& gitMetadataReceiptCrossed(worktreeRoot, operation, 'HEAD')
|
|
288
|
-
)
|
|
289
|
-
)
|
|
290
|
-
) return;
|
|
291
|
-
throw new TransitionError(
|
|
292
|
-
'move-head-to-card',
|
|
293
|
-
worktreeRoot,
|
|
294
|
-
'local HEAD reached the target without this ordered apply',
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
206
|
if (before.branch !== expectedBranch || before.oid !== expectedOid) {
|
|
298
|
-
throw new TransitionError('move-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (targetBranch) {
|
|
302
|
-
const observedTarget = await branchOid(worktreeRoot, targetBranch);
|
|
303
|
-
if (observedTarget !== targetOid) {
|
|
304
|
-
if (observedTarget !== (expectedTargetOid || null)) {
|
|
305
|
-
throw new TransitionError(
|
|
306
|
-
'move-head-to-card',
|
|
307
|
-
worktreeRoot,
|
|
308
|
-
`target branch changed from ${expectedTargetOid || '(absent)'} to ${observedTarget || '(absent)'}`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
await updateBranchRefs(worktreeRoot, [{
|
|
312
|
-
branch: targetBranch,
|
|
313
|
-
expectedOid: observedTarget,
|
|
314
|
-
oid: targetOid,
|
|
315
|
-
}], operation);
|
|
316
|
-
}
|
|
207
|
+
throw new TransitionError('move-to-card', worktreeRoot, 'local card changed before movement');
|
|
317
208
|
}
|
|
318
209
|
|
|
319
|
-
if (
|
|
320
|
-
await
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
210
|
+
if (before.branch === targetBranch) {
|
|
211
|
+
await fastForwardTo(worktreeRoot, targetOid);
|
|
212
|
+
} else {
|
|
213
|
+
await run(
|
|
214
|
+
'switch-card',
|
|
215
|
+
worktreeRoot,
|
|
216
|
+
['-C', worktreeRoot, 'checkout', '-B', targetBranch, targetOid],
|
|
217
|
+
{ timeoutMs: 60_000 },
|
|
218
|
+
);
|
|
327
219
|
}
|
|
328
220
|
|
|
329
221
|
const after = await currentHead(worktreeRoot);
|
|
330
|
-
if (after.branch !==
|
|
222
|
+
if (after.branch !== targetBranch || after.oid !== targetOid) {
|
|
331
223
|
throw new TransitionError(
|
|
332
|
-
'move-
|
|
224
|
+
'move-to-card',
|
|
333
225
|
worktreeRoot,
|
|
334
|
-
`expected ${targetBranch
|
|
226
|
+
`expected ${targetBranch}@${targetOid}, observed ${after.branch}@${after.oid}`,
|
|
335
227
|
);
|
|
336
228
|
}
|
|
337
229
|
}
|
|
338
230
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
)).trim();
|
|
231
|
+
/** Keep a commit reachable under one content-derived internal ref. */
|
|
232
|
+
function parkCommit(worktreeRoot, oid) {
|
|
233
|
+
const ref = `refs/amalgm/parked/${oid}`;
|
|
234
|
+
return run('park-commit', worktreeRoot, [
|
|
235
|
+
'-C', worktreeRoot, 'update-ref', ref, oid,
|
|
236
|
+
]).then(() => ref);
|
|
346
237
|
}
|
|
347
238
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (gitMetadataReceiptCrossed(worktreeRoot, operation, 'index')) {
|
|
360
|
-
if (testHooks.retainReceipt !== true) {
|
|
361
|
-
releaseGitMetadataReceipts(worktreeRoot, operation, 'index');
|
|
362
|
-
}
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
if (fs.existsSync(file)) return;
|
|
366
|
-
if (typeof testHooks.afterAbsent === 'function') {
|
|
367
|
-
await testHooks.afterAbsent();
|
|
368
|
-
}
|
|
369
|
-
const stateDir = path.join(AMALGM_DIR, 'state');
|
|
370
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
371
|
-
const scratch = path.join(
|
|
372
|
-
stateDir,
|
|
373
|
-
`empty-index-${process.pid}-${crypto.randomBytes(8).toString('hex')}`,
|
|
374
|
-
);
|
|
375
|
-
try {
|
|
376
|
-
await run(
|
|
377
|
-
'prepare-empty-index',
|
|
378
|
-
worktreeRoot,
|
|
379
|
-
['-C', worktreeRoot, 'read-tree', '--empty'],
|
|
380
|
-
{ env: { GIT_INDEX_FILE: scratch } },
|
|
381
|
-
);
|
|
382
|
-
applyGitMetadataTransaction(
|
|
383
|
-
worktreeRoot,
|
|
384
|
-
'index',
|
|
385
|
-
null,
|
|
386
|
-
fs.readFileSync(scratch),
|
|
387
|
-
operation,
|
|
388
|
-
);
|
|
389
|
-
if (testHooks.retainReceipt !== true) {
|
|
390
|
-
releaseGitMetadataReceipts(worktreeRoot, operation, 'index');
|
|
391
|
-
}
|
|
392
|
-
} finally {
|
|
393
|
-
fs.rmSync(scratch, { force: true });
|
|
239
|
+
/** Delete internal parked refs that are no longer part of shared checkpoint state. */
|
|
240
|
+
async function pruneParkedCommits(worktreeRoot, keepOids) {
|
|
241
|
+
const output = await run('list-parked-commits', worktreeRoot, [
|
|
242
|
+
'-C', worktreeRoot, 'for-each-ref', '--format=%(refname)', 'refs/amalgm/parked',
|
|
243
|
+
], { journalResult: false });
|
|
244
|
+
const prefix = 'refs/amalgm/parked/';
|
|
245
|
+
for (const ref of output.trim().split('\n').filter(Boolean)) {
|
|
246
|
+
if (!ref.startsWith(prefix) || keepOids.has(ref.slice(prefix.length))) continue;
|
|
247
|
+
await run('remove-parked-commit', worktreeRoot, [
|
|
248
|
+
'-C', worktreeRoot, 'update-ref', '-d', ref,
|
|
249
|
+
]);
|
|
394
250
|
}
|
|
395
251
|
}
|
|
396
252
|
|
|
253
|
+
function setBranchRef(worktreeRoot, branch, oid) {
|
|
254
|
+
return run('set-branch-ref', worktreeRoot, [
|
|
255
|
+
'-C', worktreeRoot, 'update-ref', `refs/heads/${branch}`, oid,
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function deleteBranchRef(worktreeRoot, branch) {
|
|
260
|
+
return run('delete-branch-ref', worktreeRoot, [
|
|
261
|
+
'-C', worktreeRoot, 'update-ref', '-d', `refs/heads/${branch}`,
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
|
|
397
265
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
266
|
+
* Exact card movement after the caller has durably parked the old commit
|
|
267
|
+
* and captured the working tree. This deliberately discards the local
|
|
268
|
+
* checkout copy; the caller immediately pours the preserved work back as
|
|
269
|
+
* the checkpoint layer.
|
|
400
270
|
*/
|
|
401
|
-
async function
|
|
271
|
+
async function forceMoveToCard(worktreeRoot, {
|
|
272
|
+
expectedBranch,
|
|
273
|
+
expectedOid,
|
|
402
274
|
targetBranch,
|
|
403
275
|
targetOid,
|
|
404
|
-
operation,
|
|
405
276
|
}) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
throw new TransitionError('initialize-head-to-card', worktreeRoot, 'repository already has another card');
|
|
410
|
-
} catch (error) {
|
|
411
|
-
if (!(error instanceof TransitionError) || error.action !== 'observe-head') throw error;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
let expectedBranch = null;
|
|
415
|
-
try {
|
|
416
|
-
expectedBranch = (await run(
|
|
417
|
-
'observe-unborn-branch',
|
|
418
|
-
worktreeRoot,
|
|
419
|
-
['-C', worktreeRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD'],
|
|
420
|
-
{ journalResult: false },
|
|
421
|
-
)).trim() || null;
|
|
422
|
-
} catch (error) {
|
|
423
|
-
if (!(error instanceof TransitionError)) throw error;
|
|
277
|
+
const before = await currentHead(worktreeRoot);
|
|
278
|
+
if (before.branch !== expectedBranch || before.oid !== expectedOid) {
|
|
279
|
+
throw new TransitionError('force-move-to-card', worktreeRoot, 'local card changed before movement');
|
|
424
280
|
}
|
|
425
281
|
if (targetBranch) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
if (observedTarget === null) {
|
|
435
|
-
await updateBranchRefs(worktreeRoot, [{
|
|
436
|
-
branch: targetBranch,
|
|
437
|
-
expectedOid: null,
|
|
438
|
-
oid: targetOid,
|
|
439
|
-
}], operation);
|
|
440
|
-
}
|
|
282
|
+
await run('force-move-to-card', worktreeRoot, [
|
|
283
|
+
'-C', worktreeRoot, 'checkout', '--force', '-B', targetBranch, targetOid,
|
|
284
|
+
], { timeoutMs: 60_000 });
|
|
285
|
+
} else {
|
|
286
|
+
await run('force-move-to-card', worktreeRoot, [
|
|
287
|
+
'-C', worktreeRoot, 'checkout', '--force', '--detach', targetOid,
|
|
288
|
+
], { timeoutMs: 60_000 });
|
|
441
289
|
}
|
|
442
|
-
await compareAndSwapHead(worktreeRoot, {
|
|
443
|
-
expectedBranch,
|
|
444
|
-
expectedOid: null,
|
|
445
|
-
targetBranch,
|
|
446
|
-
targetOid,
|
|
447
|
-
operation,
|
|
448
|
-
});
|
|
449
290
|
const after = await currentHead(worktreeRoot);
|
|
450
291
|
if (after.branch !== (targetBranch || null) || after.oid !== targetOid) {
|
|
451
|
-
throw new TransitionError(
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Move a set of branch refs as one compare-and-swap transaction.
|
|
457
|
-
* Each change is { branch, expectedOid, oid }; null expectedOid means create
|
|
458
|
-
* only if absent, and null oid means delete only from the expected commit.
|
|
459
|
-
*/
|
|
460
|
-
function branchReceiptPrefix(operation) {
|
|
461
|
-
if (!/^[0-9a-f]{64}$/.test(operation || '')) {
|
|
462
|
-
throw new Error('branch receipt has no operation identity');
|
|
463
|
-
}
|
|
464
|
-
return `refs/amalgm/apply/${operation}/branches`;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function branchReceiptRef(operation, branch) {
|
|
468
|
-
if (typeof branch !== 'string' || branch.length === 0) {
|
|
469
|
-
throw new Error('branch receipt has no branch identity');
|
|
470
|
-
}
|
|
471
|
-
const digest = crypto.createHash('sha256').update(branch).digest('hex');
|
|
472
|
-
return `${branchReceiptPrefix(operation)}/${digest}`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async function updateBranchRefs(
|
|
476
|
-
worktreeRoot,
|
|
477
|
-
changes,
|
|
478
|
-
operation = null,
|
|
479
|
-
testHooks = {},
|
|
480
|
-
) {
|
|
481
|
-
if (changes.length === 0) return Promise.resolve('');
|
|
482
|
-
const deletions = changes.filter((change) => change.oid === null);
|
|
483
|
-
const nonDeletions = changes.filter((change) => change.oid !== null);
|
|
484
|
-
if (deletions.length > 0 && nonDeletions.length > 0) {
|
|
485
|
-
const checked = new Set(
|
|
486
|
-
(await listedWorktreeHeads(worktreeRoot))
|
|
487
|
-
.map((entry) => entry.branch)
|
|
488
|
-
.filter(Boolean),
|
|
489
|
-
);
|
|
490
|
-
if (nonDeletions.some((change) => checked.has(change.branch))) {
|
|
491
|
-
await updateBranchRefs(worktreeRoot, nonDeletions, operation);
|
|
492
|
-
if (typeof testHooks.afterNonDeletionBatch === 'function') {
|
|
493
|
-
await testHooks.afterNonDeletionBatch();
|
|
494
|
-
}
|
|
495
|
-
return updateBranchRefs(worktreeRoot, deletions, operation, testHooks);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
let input = 'start\0';
|
|
499
|
-
for (const { branch, expectedOid, oid } of changes) {
|
|
500
|
-
const ref = `refs/heads/${branch}`;
|
|
501
|
-
if (oid === null) {
|
|
502
|
-
input += `delete ${ref}\0${expectedOid}\0`;
|
|
503
|
-
} else if (expectedOid === null) {
|
|
504
|
-
input += `create ${ref}\0${oid}\0`;
|
|
505
|
-
} else {
|
|
506
|
-
input += `update ${ref}\0${oid}\0${expectedOid}\0`;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
if (operation !== null) {
|
|
510
|
-
for (const change of changes) {
|
|
511
|
-
const receiptOid = change.oid || change.expectedOid;
|
|
512
|
-
if (!/^[0-9a-f]{40,64}$/.test(receiptOid || '')) {
|
|
513
|
-
throw new Error('branch receipt has no object');
|
|
514
|
-
}
|
|
515
|
-
input += `create ${branchReceiptRef(operation, change.branch)}\0${receiptOid}\0`;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
input += 'prepare\0commit\0';
|
|
519
|
-
const apply = (commonRoot = null) => run(
|
|
520
|
-
'update-branch-refs',
|
|
521
|
-
worktreeRoot,
|
|
522
|
-
commonRoot === null
|
|
523
|
-
? ['-C', worktreeRoot, 'update-ref', '--stdin', '-z']
|
|
524
|
-
: [`--git-dir=${commonRoot}`, 'update-ref', '--stdin', '-z'],
|
|
525
|
-
{ input },
|
|
526
|
-
);
|
|
527
|
-
if (!changes.some((change) => change.oid === null)) return apply();
|
|
528
|
-
return withGitHeadGuard(
|
|
529
|
-
worktreeRoot,
|
|
530
|
-
changes,
|
|
531
|
-
testHooks,
|
|
532
|
-
apply,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
async function branchReceiptCrossed(worktreeRoot, operation, branch = null) {
|
|
537
|
-
try {
|
|
538
|
-
if (branch === null) {
|
|
539
|
-
const observed = await run(
|
|
540
|
-
'observe-branch-receipts',
|
|
541
|
-
worktreeRoot,
|
|
542
|
-
[
|
|
543
|
-
'-C', worktreeRoot,
|
|
544
|
-
'for-each-ref',
|
|
545
|
-
'--count=1',
|
|
546
|
-
'--format=%(refname)',
|
|
547
|
-
`${branchReceiptPrefix(operation)}/`,
|
|
548
|
-
],
|
|
549
|
-
{ journalResult: false },
|
|
550
|
-
);
|
|
551
|
-
return observed.trim() !== '';
|
|
552
|
-
}
|
|
553
|
-
await run(
|
|
554
|
-
'observe-branch-receipt',
|
|
292
|
+
throw new TransitionError(
|
|
293
|
+
'force-move-to-card',
|
|
555
294
|
worktreeRoot,
|
|
556
|
-
|
|
557
|
-
{ journalResult: false },
|
|
295
|
+
`expected ${targetBranch || '(detached)'}@${targetOid}, observed ${after.branch || '(detached)'}@${after.oid}`,
|
|
558
296
|
);
|
|
559
|
-
return true;
|
|
560
|
-
} catch (error) {
|
|
561
|
-
if (error instanceof TransitionError) return false;
|
|
562
|
-
throw error;
|
|
563
297
|
}
|
|
564
298
|
}
|
|
565
299
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
],
|
|
576
|
-
{ journalResult: false },
|
|
577
|
-
);
|
|
578
|
-
for (const ref of observed.trim().split('\n').filter(Boolean)) {
|
|
579
|
-
await run('release-branch-receipt', worktreeRoot, [
|
|
580
|
-
'-C', worktreeRoot, 'update-ref', '-d', ref,
|
|
581
|
-
]);
|
|
582
|
-
}
|
|
300
|
+
/**
|
|
301
|
+
* Apply a captured worktree patch — or reverse one — from a file on disk.
|
|
302
|
+
* The patch file lives outside the repo (in the state dir), so what was
|
|
303
|
+
* applied stays inspectable after the fact.
|
|
304
|
+
*/
|
|
305
|
+
function applyPatch(worktreeRoot, patchFile, { reverse = false } = {}) {
|
|
306
|
+
return run(reverse ? 'reverse-patch' : 'apply-patch', worktreeRoot, [
|
|
307
|
+
'-C', worktreeRoot, 'apply', ...(reverse ? ['-R'] : []), '--whitespace=nowarn', patchFile,
|
|
308
|
+
], { timeoutMs: 60_000 });
|
|
583
309
|
}
|
|
584
310
|
|
|
585
311
|
/**
|
|
586
|
-
* Apply only the index change the reducer computed.
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* exact bytes. A concurrent staged change therefore stops the movement
|
|
590
|
-
* instead of becoming a new expected-old value.
|
|
312
|
+
* Apply only the index change the reducer computed. Git locks the index and
|
|
313
|
+
* verifies the patch's preimage, so a concurrent commit is serialized instead
|
|
314
|
+
* of being replaced by an old whole-index snapshot.
|
|
591
315
|
*/
|
|
592
|
-
async function applyIndexPatch(
|
|
593
|
-
worktreeRoot,
|
|
594
|
-
patch,
|
|
595
|
-
operation,
|
|
596
|
-
{ expectedTree, targetTree } = {},
|
|
597
|
-
) {
|
|
316
|
+
async function applyIndexPatch(worktreeRoot, patch) {
|
|
598
317
|
if (!patch) return;
|
|
599
|
-
if (!/^[0-9a-f]{64}$/.test(operation || '')) {
|
|
600
|
-
throw new Error('index movement has no durable operation identity');
|
|
601
|
-
}
|
|
602
|
-
if (
|
|
603
|
-
!/^[0-9a-f]{40,64}$/.test(expectedTree || '')
|
|
604
|
-
|| !/^[0-9a-f]{40,64}$/.test(targetTree || '')
|
|
605
|
-
) {
|
|
606
|
-
throw new Error('index movement has no sealed tree endpoints');
|
|
607
|
-
}
|
|
608
318
|
const stateDir = path.join(AMALGM_DIR, 'state');
|
|
609
319
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
610
320
|
const dir = fs.mkdtempSync(path.join(stateDir, 'index-patch-'));
|
|
611
321
|
const patchFile = path.join(dir, 'patch');
|
|
612
|
-
const scratchIndex = path.join(dir, 'index');
|
|
613
|
-
const index = gitPath(worktreeRoot, 'index');
|
|
614
322
|
try {
|
|
615
323
|
fs.writeFileSync(patchFile, patch);
|
|
616
|
-
const expected = fs.existsSync(index) ? fs.readFileSync(index) : null;
|
|
617
|
-
if (expected === null) {
|
|
618
|
-
await run(
|
|
619
|
-
'prepare-empty-index',
|
|
620
|
-
worktreeRoot,
|
|
621
|
-
['-C', worktreeRoot, 'read-tree', '--empty'],
|
|
622
|
-
{ env: { GIT_INDEX_FILE: scratchIndex } },
|
|
623
|
-
);
|
|
624
|
-
} else {
|
|
625
|
-
fs.writeFileSync(scratchIndex, expected);
|
|
626
|
-
}
|
|
627
|
-
const observedTree = (await run(
|
|
628
|
-
'verify-index-patch-source',
|
|
629
|
-
worktreeRoot,
|
|
630
|
-
['-C', worktreeRoot, 'write-tree'],
|
|
631
|
-
{
|
|
632
|
-
env: { GIT_INDEX_FILE: scratchIndex },
|
|
633
|
-
journalResult: false,
|
|
634
|
-
},
|
|
635
|
-
)).trim();
|
|
636
|
-
if (observedTree !== expectedTree) {
|
|
637
|
-
throw new TransitionError(
|
|
638
|
-
'apply-index-patch',
|
|
639
|
-
worktreeRoot,
|
|
640
|
-
`local index changed from sealed tree ${expectedTree} to ${observedTree}`,
|
|
641
|
-
);
|
|
642
|
-
}
|
|
643
324
|
await run('apply-index-patch', worktreeRoot, [
|
|
644
325
|
'-C', worktreeRoot, 'apply', '--cached', '--whitespace=nowarn', patchFile,
|
|
645
|
-
]
|
|
646
|
-
const appliedTree = (await run(
|
|
647
|
-
'verify-index-patch-target',
|
|
648
|
-
worktreeRoot,
|
|
649
|
-
['-C', worktreeRoot, 'write-tree'],
|
|
650
|
-
{
|
|
651
|
-
env: { GIT_INDEX_FILE: scratchIndex },
|
|
652
|
-
journalResult: false,
|
|
653
|
-
},
|
|
654
|
-
)).trim();
|
|
655
|
-
if (appliedTree !== targetTree) {
|
|
656
|
-
throw new Error(
|
|
657
|
-
`index patch target mismatch: expected ${targetTree}, derived ${appliedTree}`,
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
applyGitMetadataTransaction(
|
|
661
|
-
worktreeRoot,
|
|
662
|
-
'index',
|
|
663
|
-
expected,
|
|
664
|
-
fs.readFileSync(scratchIndex),
|
|
665
|
-
operation,
|
|
666
|
-
);
|
|
326
|
+
]);
|
|
667
327
|
} finally {
|
|
668
328
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
669
329
|
}
|
|
670
330
|
}
|
|
671
331
|
|
|
332
|
+
/** Set only Git's index; working files are left exactly as they are. */
|
|
333
|
+
function setIndexTree(worktreeRoot, treeOid) {
|
|
334
|
+
return run('set-index-tree', worktreeRoot, [
|
|
335
|
+
'-C', worktreeRoot, 'read-tree', treeOid,
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
|
|
672
339
|
/**
|
|
673
|
-
*
|
|
674
|
-
* { path, content, mode } (content a
|
|
340
|
+
* Land a merged write set in a working tree — the coexistence engine's
|
|
341
|
+
* only mutation path. Each entry is { path, content, mode } (content a
|
|
675
342
|
* Buffer, mode '100644' | '100755' | '120000') or { path, content: null }
|
|
676
343
|
* for a deletion. Paths are confined to the worktree; the whole set lands
|
|
677
344
|
* as one journal line.
|
|
678
345
|
*/
|
|
679
|
-
function
|
|
680
|
-
const root =
|
|
681
|
-
const operation = typeof label === 'string' && label
|
|
682
|
-
? label
|
|
683
|
-
: `anonymous-${crypto.randomBytes(32).toString('hex')}`;
|
|
346
|
+
function applyMergedFiles(worktreeRoot, writes, label) {
|
|
347
|
+
const root = path.resolve(worktreeRoot);
|
|
684
348
|
const written = [];
|
|
685
349
|
const removed = [];
|
|
686
350
|
try {
|
|
687
|
-
ensureFileTransactionExcluded(root);
|
|
688
|
-
recoverFileTransactions(root);
|
|
689
351
|
for (const write of writes) {
|
|
690
|
-
|
|
691
|
-
if (
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
journal({ action: 'apply-files', worktree: root, operation, written, removed, ok: true });
|
|
695
|
-
return { operation };
|
|
696
|
-
} catch (error) {
|
|
697
|
-
journal({
|
|
698
|
-
action: 'apply-files', worktree: root, operation, written, removed, ok: false,
|
|
699
|
-
error: String(error.message).slice(0, 500),
|
|
700
|
-
});
|
|
701
|
-
throw new TransitionError('apply-files', root, String(error.message));
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function gitDirectory(worktreeRoot) {
|
|
706
|
-
const marker = path.join(worktreeRoot, '.git');
|
|
707
|
-
if (fs.statSync(marker).isDirectory()) return marker;
|
|
708
|
-
const match = fs.readFileSync(marker, 'utf8').trim().match(/^gitdir:\s*(.+)$/);
|
|
709
|
-
if (!match) throw new Error('worktree has no Git directory');
|
|
710
|
-
return path.resolve(worktreeRoot, match[1]);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function gitPath(worktreeRoot, relativePath) {
|
|
714
|
-
const value = execFileSync(
|
|
715
|
-
'git',
|
|
716
|
-
['-C', worktreeRoot, 'rev-parse', '--git-path', relativePath],
|
|
717
|
-
{
|
|
718
|
-
cwd: worktreeRoot,
|
|
719
|
-
env: { ...process.env, ...TRANSITION_ENV },
|
|
720
|
-
encoding: 'utf8',
|
|
721
|
-
timeout: 120_000,
|
|
722
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
723
|
-
},
|
|
724
|
-
).trim();
|
|
725
|
-
return path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function fileTransactionDirectory(worktreeRoot) {
|
|
729
|
-
return path.join(gitDirectory(worktreeRoot), 'amalgm-apply');
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/**
|
|
733
|
-
* Receipts live beside their destination so the kernel can consume prepared
|
|
734
|
-
* targets atomically even when a linked worktree's Git directory is on
|
|
735
|
-
* another device. They are implementation state, never workspace content.
|
|
736
|
-
*/
|
|
737
|
-
function isApplyReceiptPath(relativePath) {
|
|
738
|
-
return String(relativePath)
|
|
739
|
-
.split(/[\\/]/)
|
|
740
|
-
.some((component) => APPLY_RECEIPT_DIRECTORY.test(component));
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
function ensureFileTransactionExcluded(worktreeRoot) {
|
|
744
|
-
// Linked worktrees have a private Git directory, but Git reads exclusions
|
|
745
|
-
// from the common path named by `--git-path`. The command is the authority;
|
|
746
|
-
// reconstructing this path from `.git` makes receipt bytes look user-owned.
|
|
747
|
-
const exclude = gitPath(worktreeRoot, 'info/exclude');
|
|
748
|
-
let content = '';
|
|
749
|
-
let mode = 0o644;
|
|
750
|
-
try {
|
|
751
|
-
content = fs.readFileSync(exclude, 'utf8');
|
|
752
|
-
mode = fs.statSync(exclude).mode & 0o777;
|
|
753
|
-
} catch (error) {
|
|
754
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
755
|
-
}
|
|
756
|
-
if (content.split(/\r?\n/).includes(APPLY_RECEIPT_EXCLUDE)) return;
|
|
757
|
-
fs.mkdirSync(path.dirname(exclude), { recursive: true });
|
|
758
|
-
// Append instead of replacing a read snapshot: a concurrent user edit to
|
|
759
|
-
// info/exclude must survive. A racing apply may append the same harmless
|
|
760
|
-
// rule twice, while every later call observes it and stops.
|
|
761
|
-
const descriptor = fs.openSync(exclude, 'a', mode);
|
|
762
|
-
try {
|
|
763
|
-
fs.writeFileSync(descriptor, `\n${APPLY_RECEIPT_EXCLUDE}\n`);
|
|
764
|
-
fs.fsyncSync(descriptor);
|
|
765
|
-
} finally {
|
|
766
|
-
fs.closeSync(descriptor);
|
|
767
|
-
}
|
|
768
|
-
syncDirectory(path.dirname(exclude));
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function ensureDurableDirectory(directory) {
|
|
772
|
-
try {
|
|
773
|
-
fs.mkdirSync(directory);
|
|
774
|
-
syncDirectory(path.dirname(directory));
|
|
775
|
-
} catch (error) {
|
|
776
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
777
|
-
if (!fs.statSync(directory).isDirectory()) {
|
|
778
|
-
throw new Error(`workspace transaction path is not a directory: ${directory}`);
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function confinedPath(worktreeRoot, relativePath) {
|
|
784
|
-
if (
|
|
785
|
-
typeof relativePath !== 'string'
|
|
786
|
-
|| !relativePath
|
|
787
|
-
|| path.isAbsolute(relativePath)
|
|
788
|
-
|| relativePath.split(/[\\/]/).includes('..')
|
|
789
|
-
) throw new Error(`unsafe path: ${relativePath}`);
|
|
790
|
-
const root = fs.realpathSync(worktreeRoot);
|
|
791
|
-
const absolute = path.resolve(root, relativePath);
|
|
792
|
-
if (!absolute.startsWith(root + path.sep)) throw new Error(`unsafe path: ${relativePath}`);
|
|
793
|
-
let current = root;
|
|
794
|
-
for (const component of relativePath.split(/[\\/]/).slice(0, -1)) {
|
|
795
|
-
if (!component || component === '.') continue;
|
|
796
|
-
current = path.join(current, component);
|
|
797
|
-
let stat;
|
|
798
|
-
try {
|
|
799
|
-
stat = fs.lstatSync(current);
|
|
800
|
-
} catch (error) {
|
|
801
|
-
if (error?.code === 'ENOENT') break;
|
|
802
|
-
throw error;
|
|
803
|
-
}
|
|
804
|
-
if (stat.isSymbolicLink()) {
|
|
805
|
-
throw new Error(`unsafe symlink parent: ${relativePath}`);
|
|
806
|
-
}
|
|
807
|
-
if (!stat.isDirectory()) throw new Error(`unsafe non-directory parent: ${relativePath}`);
|
|
808
|
-
}
|
|
809
|
-
return absolute;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
function fileStateIdentity(state) {
|
|
813
|
-
if (state === null) return null;
|
|
814
|
-
if (state?.kind === 'other') return { kind: 'other' };
|
|
815
|
-
if (!Buffer.isBuffer(state?.content)) throw new Error('file state has no exact bytes');
|
|
816
|
-
return {
|
|
817
|
-
kind: state.kind,
|
|
818
|
-
exec: state.exec === true,
|
|
819
|
-
bytes: state.content.length,
|
|
820
|
-
sha256: crypto.createHash('sha256').update(state.content).digest('hex'),
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function transactionPaths(worktreeRoot, operation, relativePath, rootStat) {
|
|
825
|
-
const recordDirectory = fileTransactionDirectory(worktreeRoot);
|
|
826
|
-
ensureDurableDirectory(recordDirectory);
|
|
827
|
-
const id = crypto.createHash('sha256')
|
|
828
|
-
.update(
|
|
829
|
-
`${FILE_TRANSACTION_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
|
|
830
|
-
+ `\0${operation}\0${relativePath}`,
|
|
831
|
-
)
|
|
832
|
-
.digest('hex');
|
|
833
|
-
return {
|
|
834
|
-
id,
|
|
835
|
-
recordDirectory,
|
|
836
|
-
record: path.join(recordDirectory, `${id}.json`),
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function removeTransactionRecord(record) {
|
|
841
|
-
try {
|
|
842
|
-
fs.unlinkSync(record);
|
|
843
|
-
} catch (error) {
|
|
844
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
845
|
-
}
|
|
846
|
-
syncDirectory(path.dirname(record));
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function readFileTransaction(record) {
|
|
850
|
-
try {
|
|
851
|
-
const value = JSON.parse(fs.readFileSync(record, 'utf8'));
|
|
852
|
-
if (
|
|
853
|
-
value?.contract !== FILE_TRANSACTION_CONTRACT
|
|
854
|
-
|| typeof value.root !== 'string'
|
|
855
|
-
|| !/^\d+$/.test(value.rootDev)
|
|
856
|
-
|| !/^\d+$/.test(value.rootIno)
|
|
857
|
-
|| typeof value.path !== 'string'
|
|
858
|
-
|| typeof value.id !== 'string'
|
|
859
|
-
|| typeof value.operation !== 'string'
|
|
860
|
-
|| !value.operation
|
|
861
|
-
|| value.id !== path.basename(record, '.json')
|
|
862
|
-
|| !/^[a-f0-9]{64}$/.test(value.id)
|
|
863
|
-
|| (value.expected !== null && typeof value.expected !== 'object')
|
|
864
|
-
|| (value.target !== null && typeof value.target !== 'object')
|
|
865
|
-
) throw new Error('invalid file transaction');
|
|
866
|
-
return { ...value, record };
|
|
867
|
-
} catch (error) {
|
|
868
|
-
throw new Error(`invalid workspace file transaction ${record}: ${error.message}`);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function runNativeFileTransaction(transaction, action, content = null, testHooks = {}) {
|
|
873
|
-
try {
|
|
874
|
-
return nativeFileTransaction.run(transaction, action, content, testHooks);
|
|
875
|
-
} catch (error) {
|
|
876
|
-
throw new Error(
|
|
877
|
-
String(error?.message || error).replace(
|
|
878
|
-
/^native file transaction/,
|
|
879
|
-
'native workspace file transaction',
|
|
880
|
-
),
|
|
881
|
-
);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function fileTransactionRecords(worktreeRoot, operation = null) {
|
|
886
|
-
const directory = fileTransactionDirectory(worktreeRoot);
|
|
887
|
-
if (!fs.existsSync(directory)) return [];
|
|
888
|
-
return fs.readdirSync(directory)
|
|
889
|
-
.filter((name) => name.endsWith('.json'))
|
|
890
|
-
.sort()
|
|
891
|
-
.map((name) => readFileTransaction(path.join(directory, name)))
|
|
892
|
-
.filter((transaction) => operation === null || transaction.operation === operation);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Resolve an interrupted expected-source → official-target file movement
|
|
897
|
-
* before any new repo observation. The retained source is either the exact
|
|
898
|
-
* expected floor (safe to finish/rollback) or a third value (a local save
|
|
899
|
-
* that is restored without overwriting anything newer).
|
|
900
|
-
*/
|
|
901
|
-
function recoverFileTransactions(worktreeRoot) {
|
|
902
|
-
const root = fs.realpathSync(worktreeRoot);
|
|
903
|
-
ensureFileTransactionExcluded(root);
|
|
904
|
-
for (const transaction of fileTransactionRecords(root)) {
|
|
905
|
-
if (transaction.root !== root) {
|
|
906
|
-
throw new Error(`workspace file transaction escaped its repository: ${transaction.record}`);
|
|
907
|
-
}
|
|
908
|
-
const result = runNativeFileTransaction(transaction, 'recover');
|
|
909
|
-
if (result.status === 'pending') {
|
|
910
|
-
throw new Error(
|
|
911
|
-
`workspace file transaction is waiting for local writer ownership: `
|
|
912
|
-
+ `${transaction.path}: ${result.reason || 'pending'}`,
|
|
913
|
-
);
|
|
914
|
-
}
|
|
915
|
-
// A crossed operation is evidence until the verified cursor passes it.
|
|
916
|
-
// Only a transaction proven not to have crossed can retire here.
|
|
917
|
-
if (result.status === 'changed') removeTransactionRecord(transaction.record);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
function sameTransaction(left, right) {
|
|
922
|
-
return (
|
|
923
|
-
left.contract === right.contract
|
|
924
|
-
&& left.root === right.root
|
|
925
|
-
&& left.rootDev === right.rootDev
|
|
926
|
-
&& left.rootIno === right.rootIno
|
|
927
|
-
&& left.path === right.path
|
|
928
|
-
&& left.id === right.id
|
|
929
|
-
&& left.operation === right.operation
|
|
930
|
-
&& JSON.stringify(left.expected) === JSON.stringify(right.expected)
|
|
931
|
-
&& JSON.stringify(left.target) === JSON.stringify(right.target)
|
|
932
|
-
);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function applyFileTransaction(worktreeRoot, write, operation) {
|
|
936
|
-
if (typeof operation !== 'string' || !operation || operation.length > 1024) {
|
|
937
|
-
throw new Error('workspace file transaction has no operation identity');
|
|
938
|
-
}
|
|
939
|
-
const rootStat = fs.statSync(worktreeRoot, { bigint: true });
|
|
940
|
-
const paths = transactionPaths(worktreeRoot, operation, write.path, rootStat);
|
|
941
|
-
const target = write.content === null
|
|
942
|
-
? null
|
|
943
|
-
: {
|
|
944
|
-
...fileStateIdentity({
|
|
945
|
-
kind: write.mode === '120000' ? 'symlink' : 'file',
|
|
946
|
-
content: write.content,
|
|
947
|
-
exec: write.mode === '100755',
|
|
948
|
-
}),
|
|
949
|
-
};
|
|
950
|
-
const transaction = {
|
|
951
|
-
contract: FILE_TRANSACTION_CONTRACT,
|
|
952
|
-
root: worktreeRoot,
|
|
953
|
-
rootDev: rootStat.dev.toString(),
|
|
954
|
-
rootIno: rootStat.ino.toString(),
|
|
955
|
-
path: write.path,
|
|
956
|
-
id: paths.id,
|
|
957
|
-
operation,
|
|
958
|
-
expected: fileStateIdentity(write.expected),
|
|
959
|
-
target,
|
|
960
|
-
};
|
|
961
|
-
if (fs.existsSync(paths.record)) {
|
|
962
|
-
const retained = readFileTransaction(paths.record);
|
|
963
|
-
if (!sameTransaction(retained, transaction)) {
|
|
964
|
-
throw new Error(`workspace file transaction identity changed: ${write.path}`);
|
|
965
|
-
}
|
|
966
|
-
const recovered = runNativeFileTransaction(retained, 'recover');
|
|
967
|
-
if (recovered.status === 'applied') return;
|
|
968
|
-
if (recovered.status === 'pending') {
|
|
969
|
-
throw new Error(
|
|
970
|
-
`local writer ownership is unresolved: ${write.path}: `
|
|
971
|
-
+ `${recovered.reason || 'pending'}`,
|
|
972
|
-
);
|
|
973
|
-
}
|
|
974
|
-
removeTransactionRecord(paths.record);
|
|
975
|
-
}
|
|
976
|
-
durable.writeJson(paths.record, transaction);
|
|
977
|
-
const result = runNativeFileTransaction(
|
|
978
|
-
transaction,
|
|
979
|
-
'apply',
|
|
980
|
-
write.content,
|
|
981
|
-
{
|
|
982
|
-
pauseAfterParentOpen: Number(write.testPauseAfterParentOpenMillis || 0),
|
|
983
|
-
pauseAfterExtraction: Number(write.testPauseAfterExtractionMillis || 0),
|
|
984
|
-
pauseBeforeInstall: Number(write.testPauseBeforeInstallMillis || 0),
|
|
985
|
-
crashAfterExtraction: write.testCrashAfterExtraction === true,
|
|
986
|
-
crashBeforeInstall: write.testCrashBeforeInstall === true,
|
|
987
|
-
},
|
|
988
|
-
);
|
|
989
|
-
if (result.status === 'pending') {
|
|
990
|
-
throw new Error(
|
|
991
|
-
`local writer ownership is unresolved: ${write.path}: ${result.reason || 'pending'}`,
|
|
992
|
-
);
|
|
993
|
-
}
|
|
994
|
-
if (result.status === 'changed') {
|
|
995
|
-
removeTransactionRecord(paths.record);
|
|
996
|
-
throw new Error(`local file changed before ordered apply: ${write.path}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* The transaction artifact, not ambient file metadata, says whether an
|
|
1002
|
-
* operation crossed. A later local write may return the pathname to either
|
|
1003
|
-
* endpoint without changing this answer.
|
|
1004
|
-
*/
|
|
1005
|
-
function crossedFileReceiptPaths(worktreeRoot, operation, relativePaths) {
|
|
1006
|
-
const root = fs.realpathSync(worktreeRoot);
|
|
1007
|
-
const requested = new Set(relativePaths);
|
|
1008
|
-
const crossed = new Set();
|
|
1009
|
-
recoverFileTransactions(root);
|
|
1010
|
-
for (const transaction of fileTransactionRecords(root, operation)) {
|
|
1011
|
-
if (!requested.has(transaction.path)) continue;
|
|
1012
|
-
const result = runNativeFileTransaction(transaction, 'receipt');
|
|
1013
|
-
if (result.status === 'crossed') crossed.add(transaction.path);
|
|
1014
|
-
else if (result.status !== 'not-crossed') {
|
|
1015
|
-
throw new Error(
|
|
1016
|
-
`workspace file receipt is unresolved: ${transaction.path}: `
|
|
1017
|
-
+ `${result.reason || result.status}`,
|
|
1018
|
-
);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
return crossed;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
/**
|
|
1025
|
-
* Receipt retirement is safe only after the caller's verified cursor is
|
|
1026
|
-
* durable. Repeating release after any crash is harmless.
|
|
1027
|
-
*/
|
|
1028
|
-
function releaseFileReceipts(worktreeRoot, operation) {
|
|
1029
|
-
const root = fs.realpathSync(worktreeRoot);
|
|
1030
|
-
for (const transaction of fileTransactionRecords(root, operation)) {
|
|
1031
|
-
const result = runNativeFileTransaction(transaction, 'release');
|
|
1032
|
-
if (result.status === 'pending') {
|
|
1033
|
-
throw new Error(
|
|
1034
|
-
`workspace file receipt cannot be released: ${transaction.path}: `
|
|
1035
|
-
+ `${result.reason || 'pending'}`,
|
|
1036
|
-
);
|
|
1037
|
-
}
|
|
1038
|
-
if (result.status !== 'released') {
|
|
1039
|
-
throw new Error(
|
|
1040
|
-
`workspace file receipt release failed: ${transaction.path}: ${result.status}`,
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
removeTransactionRecord(transaction.record);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function gitMetadataTransactionDirectoryForRoot(root) {
|
|
1048
|
-
return path.join(fs.realpathSync(root), 'amalgm-metadata-apply');
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function gitMetadataTransactionDirectory(worktreeRoot) {
|
|
1052
|
-
return gitMetadataTransactionDirectoryForRoot(gitDirectory(worktreeRoot));
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
async function listedWorktreeHeads(worktreeRoot) {
|
|
1056
|
-
const output = await run(
|
|
1057
|
-
'observe-worktree-heads',
|
|
1058
|
-
worktreeRoot,
|
|
1059
|
-
['-C', worktreeRoot, 'worktree', 'list', '--porcelain', '-z'],
|
|
1060
|
-
{ journalResult: false },
|
|
1061
|
-
);
|
|
1062
|
-
const entries = [];
|
|
1063
|
-
let current = null;
|
|
1064
|
-
for (const token of output.split('\0')) {
|
|
1065
|
-
if (!token) {
|
|
1066
|
-
if (current?.path) entries.push(current);
|
|
1067
|
-
current = null;
|
|
1068
|
-
} else if (token.startsWith('worktree ')) {
|
|
1069
|
-
current = { path: token.slice('worktree '.length), branch: null };
|
|
1070
|
-
} else if (current && token.startsWith('branch refs/heads/')) {
|
|
1071
|
-
current.branch = token.slice('branch refs/heads/'.length);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
if (current?.path) entries.push(current);
|
|
1075
|
-
return entries;
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
async function withOneGitHeadGuard(
|
|
1079
|
-
gitRoot,
|
|
1080
|
-
label,
|
|
1081
|
-
changes,
|
|
1082
|
-
guarded,
|
|
1083
|
-
) {
|
|
1084
|
-
const root = fs.realpathSync(gitRoot);
|
|
1085
|
-
recoverGitMetadataTransactionsAtRoot(root, label);
|
|
1086
|
-
const head = path.join(root, 'HEAD');
|
|
1087
|
-
const expected = fs.readFileSync(head);
|
|
1088
|
-
const attached = expected.toString('utf8').match(/^ref: refs\/heads\/(.+)\n$/)?.[1] || null;
|
|
1089
|
-
const deleted = new Set(
|
|
1090
|
-
changes.filter((change) => change.oid === null).map((change) => change.branch),
|
|
1091
|
-
);
|
|
1092
|
-
if (attached && deleted.has(attached)) {
|
|
1093
|
-
throw new TransitionError(
|
|
1094
|
-
'guard-branch-delete',
|
|
1095
|
-
label,
|
|
1096
|
-
`HEAD is attached to branch ${attached}`,
|
|
1097
|
-
);
|
|
1098
|
-
}
|
|
1099
|
-
// A logical branch operation may be attempted concurrently. Its transient
|
|
1100
|
-
// guard artifacts must still have exactly one process owner.
|
|
1101
|
-
const guardOperation = crypto.randomBytes(32).toString('hex');
|
|
1102
|
-
const rootStat = fs.statSync(root, { bigint: true });
|
|
1103
|
-
const paths = gitMetadataTransactionPathsForRoot(
|
|
1104
|
-
root,
|
|
1105
|
-
guardOperation,
|
|
1106
|
-
'HEAD',
|
|
1107
|
-
rootStat,
|
|
1108
|
-
);
|
|
1109
|
-
const identity = gitMetadataIdentity(expected);
|
|
1110
|
-
const transaction = {
|
|
1111
|
-
contract: GIT_METADATA_TRANSACTION_CONTRACT,
|
|
1112
|
-
backend: 'git-guard',
|
|
1113
|
-
root,
|
|
1114
|
-
rootDev: rootStat.dev.toString(),
|
|
1115
|
-
rootIno: rootStat.ino.toString(),
|
|
1116
|
-
path: 'HEAD',
|
|
1117
|
-
id: paths.id,
|
|
1118
|
-
operation: guardOperation,
|
|
1119
|
-
ownerPid: process.pid,
|
|
1120
|
-
ownerStarted: PROCESS_START_IDENTITY,
|
|
1121
|
-
expected: identity,
|
|
1122
|
-
target: identity,
|
|
1123
|
-
record: paths.record,
|
|
1124
|
-
prepared: paths.target,
|
|
1125
|
-
claimed: paths.claimed,
|
|
1126
|
-
};
|
|
1127
|
-
|
|
1128
|
-
if (fs.existsSync(paths.record)) {
|
|
1129
|
-
const retained = readGitMetadataTransaction(paths.record);
|
|
1130
|
-
if (!sameGitMetadataTransaction(retained, transaction)) {
|
|
1131
|
-
throw new Error('Git HEAD guard identity changed');
|
|
1132
|
-
}
|
|
1133
|
-
if (guardOwnerIsActive(retained)) {
|
|
1134
|
-
throw new TransitionError(
|
|
1135
|
-
'recover-head-guard',
|
|
1136
|
-
label,
|
|
1137
|
-
`guard owner ${retained.ownerPid} is still active`,
|
|
1138
|
-
);
|
|
1139
|
-
}
|
|
1140
|
-
const recovered = runNativeFileTransaction(retained, 'recover');
|
|
1141
|
-
if (recovered.status !== 'released') {
|
|
1142
|
-
throw new TransitionError(
|
|
1143
|
-
'recover-head-guard',
|
|
1144
|
-
label,
|
|
1145
|
-
recovered.reason || recovered.status,
|
|
1146
|
-
);
|
|
1147
|
-
}
|
|
1148
|
-
removeGitMetadataTransaction(retained);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
durable.writeJson(paths.record, {
|
|
1152
|
-
contract: transaction.contract,
|
|
1153
|
-
backend: transaction.backend,
|
|
1154
|
-
root: transaction.root,
|
|
1155
|
-
rootDev: transaction.rootDev,
|
|
1156
|
-
rootIno: transaction.rootIno,
|
|
1157
|
-
path: transaction.path,
|
|
1158
|
-
id: transaction.id,
|
|
1159
|
-
operation: transaction.operation,
|
|
1160
|
-
ownerPid: transaction.ownerPid,
|
|
1161
|
-
ownerStarted: transaction.ownerStarted,
|
|
1162
|
-
expected: transaction.expected,
|
|
1163
|
-
target: transaction.target,
|
|
1164
|
-
});
|
|
1165
|
-
durable.writeFile(paths.target, expected, 0o644);
|
|
1166
|
-
const claimed = runNativeFileTransaction(transaction, 'guard');
|
|
1167
|
-
if (claimed.status !== 'guarded') {
|
|
1168
|
-
removeGitMetadataTransaction(transaction);
|
|
1169
|
-
throw new TransitionError(
|
|
1170
|
-
'guard-branch-delete',
|
|
1171
|
-
label,
|
|
1172
|
-
claimed.reason || claimed.status,
|
|
1173
|
-
);
|
|
1174
|
-
}
|
|
1175
|
-
try {
|
|
1176
|
-
return await guarded();
|
|
1177
|
-
} finally {
|
|
1178
|
-
const released = runNativeFileTransaction(transaction, 'unguard');
|
|
1179
|
-
if (released.status !== 'released') {
|
|
1180
|
-
throw new Error(`Git HEAD guard release failed: ${released.reason || released.status}`);
|
|
1181
|
-
}
|
|
1182
|
-
removeGitMetadataTransaction(transaction);
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function gitWorktreeNamespaceTransactionDirectory() {
|
|
1187
|
-
return path.join(AMALGM_DIR, 'state', 'workspace-git-worktree-guards');
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function physicalDirectoryIdentity(directory) {
|
|
1191
|
-
const realpath = fs.realpathSync(directory);
|
|
1192
|
-
const stat = fs.statSync(realpath, { bigint: true });
|
|
1193
|
-
if (!stat.isDirectory()) {
|
|
1194
|
-
throw new Error(`physical directory identity is not a directory: ${realpath}`);
|
|
1195
|
-
}
|
|
1196
|
-
return {
|
|
1197
|
-
realpath,
|
|
1198
|
-
dev: stat.dev.toString(),
|
|
1199
|
-
ino: stat.ino.toString(),
|
|
1200
|
-
};
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
function pinPhysicalDirectory(directory) {
|
|
1204
|
-
const realpath = fs.realpathSync(directory);
|
|
1205
|
-
const fd = fs.openSync(
|
|
1206
|
-
realpath,
|
|
1207
|
-
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY,
|
|
1208
|
-
);
|
|
1209
|
-
try {
|
|
1210
|
-
const stat = fs.fstatSync(fd, { bigint: true });
|
|
1211
|
-
if (!stat.isDirectory()) {
|
|
1212
|
-
throw new Error(`physical directory pin is not a directory: ${realpath}`);
|
|
1213
|
-
}
|
|
1214
|
-
return {
|
|
1215
|
-
fd,
|
|
1216
|
-
realpath,
|
|
1217
|
-
dev: stat.dev.toString(),
|
|
1218
|
-
ino: stat.ino.toString(),
|
|
1219
|
-
};
|
|
1220
|
-
} catch (error) {
|
|
1221
|
-
fs.closeSync(fd);
|
|
1222
|
-
throw error;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
function closePhysicalDirectoryPins(pins) {
|
|
1227
|
-
for (const pin of [...pins].reverse()) fs.closeSync(pin.fd);
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
function samePhysicalDirectory(left, right) {
|
|
1231
|
-
return (
|
|
1232
|
-
left !== null
|
|
1233
|
-
&& right !== null
|
|
1234
|
-
&& left.dev === right.dev
|
|
1235
|
-
&& left.ino === right.ino
|
|
1236
|
-
);
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
function gitCommonDirectory(worktreeRoot) {
|
|
1240
|
-
const value = execFileSync(
|
|
1241
|
-
'git',
|
|
1242
|
-
['-C', worktreeRoot, 'rev-parse', '--git-common-dir'],
|
|
1243
|
-
{
|
|
1244
|
-
cwd: worktreeRoot,
|
|
1245
|
-
env: { ...process.env, ...TRANSITION_ENV },
|
|
1246
|
-
encoding: 'utf8',
|
|
1247
|
-
timeout: 120_000,
|
|
1248
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
1249
|
-
},
|
|
1250
|
-
).trim();
|
|
1251
|
-
return fs.realpathSync(path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value));
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
function gitCommonDirectoryFromMarker(worktreeRoot) {
|
|
1255
|
-
const root = fs.realpathSync(worktreeRoot);
|
|
1256
|
-
const marker = path.join(root, '.git');
|
|
1257
|
-
if (fs.statSync(marker).isDirectory()) return fs.realpathSync(marker);
|
|
1258
|
-
const match = fs.readFileSync(marker, 'utf8').trim().match(/^gitdir:\s*(.+)$/);
|
|
1259
|
-
if (!match) throw new Error('worktree has no Git directory');
|
|
1260
|
-
const privateDirectory = path.resolve(root, match[1]);
|
|
1261
|
-
const registrations = path.dirname(privateDirectory);
|
|
1262
|
-
return fs.realpathSync(
|
|
1263
|
-
path.basename(registrations) === 'worktrees'
|
|
1264
|
-
? path.dirname(registrations)
|
|
1265
|
-
: privateDirectory,
|
|
1266
|
-
);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
function physicalGitWorktreeRegistrations(directory) {
|
|
1270
|
-
return fs.readdirSync(directory)
|
|
1271
|
-
.sort()
|
|
1272
|
-
.map((name) => {
|
|
1273
|
-
const stat = fs.lstatSync(path.join(directory, name), { bigint: true });
|
|
1274
|
-
if (!stat.isDirectory()) {
|
|
1275
|
-
throw new Error(`Git worktree registration is not a directory: ${name}`);
|
|
1276
|
-
}
|
|
1277
|
-
return {
|
|
1278
|
-
name,
|
|
1279
|
-
dev: stat.dev.toString(),
|
|
1280
|
-
ino: stat.ino.toString(),
|
|
1281
|
-
};
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
function ensureGitWorktreeNamespace(commonRoot) {
|
|
1286
|
-
const directory = path.join(commonRoot, 'worktrees');
|
|
1287
|
-
try {
|
|
1288
|
-
fs.statSync(directory);
|
|
1289
|
-
} catch (error) {
|
|
1290
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
1291
|
-
fs.mkdirSync(directory, { mode: 0o755 });
|
|
1292
|
-
syncDirectory(commonRoot);
|
|
1293
|
-
}
|
|
1294
|
-
return directory;
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
function pinGitWorktreeNamespace(commonRoot) {
|
|
1298
|
-
const directory = ensureGitWorktreeNamespace(commonRoot);
|
|
1299
|
-
const pins = [];
|
|
1300
|
-
try {
|
|
1301
|
-
const namespace = pinPhysicalDirectory(directory);
|
|
1302
|
-
pins.push(namespace);
|
|
1303
|
-
const entries = fs.readdirSync(directory)
|
|
1304
|
-
.sort()
|
|
1305
|
-
.map((name) => {
|
|
1306
|
-
const pin = pinPhysicalDirectory(path.join(directory, name));
|
|
1307
|
-
pins.push(pin);
|
|
1308
|
-
return { name, dev: pin.dev, ino: pin.ino };
|
|
1309
|
-
});
|
|
1310
|
-
return { namespace, entries, pins };
|
|
1311
|
-
} catch (error) {
|
|
1312
|
-
closePhysicalDirectoryPins(pins);
|
|
1313
|
-
throw error;
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
function gitWorktreeNamespacePaths(commonRoot, operation, rootStat) {
|
|
1318
|
-
const receiptDirectory = path.join(commonRoot, 'amalgm-metadata-apply');
|
|
1319
|
-
const recordDirectory = gitWorktreeNamespaceTransactionDirectory();
|
|
1320
|
-
ensureDurableDirectory(receiptDirectory);
|
|
1321
|
-
ensureDurableDirectory(AMALGM_DIR);
|
|
1322
|
-
ensureDurableDirectory(path.join(AMALGM_DIR, 'state'));
|
|
1323
|
-
ensureDurableDirectory(recordDirectory);
|
|
1324
|
-
const id = crypto.createHash('sha256')
|
|
1325
|
-
.update(
|
|
1326
|
-
`${GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
|
|
1327
|
-
+ `\0${operation}\0worktrees`,
|
|
1328
|
-
)
|
|
1329
|
-
.digest('hex');
|
|
1330
|
-
return {
|
|
1331
|
-
id,
|
|
1332
|
-
record: path.join(recordDirectory, `${id}.json`),
|
|
1333
|
-
target: path.join(receiptDirectory, `${id}.target`),
|
|
1334
|
-
claimed: path.join(receiptDirectory, `${id}.claimed`),
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
function readGitWorktreeNamespaceTransaction(record) {
|
|
1339
|
-
try {
|
|
1340
|
-
const value = JSON.parse(fs.readFileSync(record, 'utf8'));
|
|
1341
|
-
if (
|
|
1342
|
-
value?.contract !== GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT
|
|
1343
|
-
|| value.backend !== 'git-worktree-guard'
|
|
1344
|
-
|| typeof value.worktree !== 'string'
|
|
1345
|
-
|| !path.isAbsolute(value.worktree)
|
|
1346
|
-
|| !/^\d+$/.test(value.worktreeDev)
|
|
1347
|
-
|| !/^\d+$/.test(value.worktreeIno)
|
|
1348
|
-
|| typeof value.root !== 'string'
|
|
1349
|
-
|| !path.isAbsolute(value.root)
|
|
1350
|
-
|| !/^\d+$/.test(value.rootDev)
|
|
1351
|
-
|| !/^\d+$/.test(value.rootIno)
|
|
1352
|
-
|| value.path !== 'worktrees'
|
|
1353
|
-
|| value.id !== path.basename(record, '.json')
|
|
1354
|
-
|| !/^[a-f0-9]{64}$/.test(value.id || '')
|
|
1355
|
-
|| !/^[a-f0-9]{64}$/.test(value.operation || '')
|
|
1356
|
-
|| !Number.isSafeInteger(value.ownerPid)
|
|
1357
|
-
|| value.ownerPid < 1
|
|
1358
|
-
|| typeof value.ownerStarted !== 'string'
|
|
1359
|
-
|| !value.ownerStarted
|
|
1360
|
-
|| value.expected?.kind !== 'directory'
|
|
1361
|
-
|| !/^\d+$/.test(value.expected?.dev || '')
|
|
1362
|
-
|| !/^\d+$/.test(value.expected?.ino || '')
|
|
1363
|
-
|| value.target?.kind !== 'file'
|
|
1364
|
-
|| !Array.isArray(value.entries)
|
|
1365
|
-
|| value.entries.some((entry) => (
|
|
1366
|
-
entry === null
|
|
1367
|
-
|| typeof entry !== 'object'
|
|
1368
|
-
|| typeof entry.name !== 'string'
|
|
1369
|
-
|| !entry.name
|
|
1370
|
-
|| entry.name === '.'
|
|
1371
|
-
|| entry.name === '..'
|
|
1372
|
-
|| entry.name.includes('/')
|
|
1373
|
-
|| entry.name.includes('\\')
|
|
1374
|
-
|| !/^\d+$/.test(entry.dev || '')
|
|
1375
|
-
|| !/^\d+$/.test(entry.ino || '')
|
|
1376
|
-
))
|
|
1377
|
-
) throw new Error('invalid Git worktree namespace transaction');
|
|
1378
|
-
return {
|
|
1379
|
-
...value,
|
|
1380
|
-
record,
|
|
1381
|
-
prepared: path.join(value.root, 'amalgm-metadata-apply', `${value.id}.target`),
|
|
1382
|
-
claimed: path.join(value.root, 'amalgm-metadata-apply', `${value.id}.claimed`),
|
|
1383
|
-
};
|
|
1384
|
-
} catch (error) {
|
|
1385
|
-
throw new Error(
|
|
1386
|
-
`invalid workspace Git worktree namespace transaction ${record}: ${error.message}`,
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
function gitWorktreeNamespaceTransactionRecords(worktreeRoot) {
|
|
1392
|
-
const directory = gitWorktreeNamespaceTransactionDirectory();
|
|
1393
|
-
if (!fs.existsSync(directory)) return [];
|
|
1394
|
-
const requested = physicalDirectoryIdentity(worktreeRoot);
|
|
1395
|
-
let common = null;
|
|
1396
|
-
try {
|
|
1397
|
-
common = physicalDirectoryIdentity(gitCommonDirectoryFromMarker(worktreeRoot));
|
|
1398
|
-
} catch {
|
|
1399
|
-
// A nonstandard Git layout may require Git's own common-dir resolution.
|
|
1400
|
-
// That is available whenever the shared registration namespace is open.
|
|
1401
|
-
try {
|
|
1402
|
-
common = physicalDirectoryIdentity(gitCommonDirectory(worktreeRoot));
|
|
1403
|
-
} catch {
|
|
1404
|
-
// The checkout identity may still surface a record for diagnosis, but
|
|
1405
|
-
// only a resolved matching common repository can authorize recovery.
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
return fs.readdirSync(directory)
|
|
1409
|
-
.filter((name) => name.endsWith('.json'))
|
|
1410
|
-
.sort()
|
|
1411
|
-
.map((name) => readGitWorktreeNamespaceTransaction(path.join(directory, name)))
|
|
1412
|
-
.map((transaction) => {
|
|
1413
|
-
const worktreeMatches = samePhysicalDirectory(
|
|
1414
|
-
{ dev: transaction.worktreeDev, ino: transaction.worktreeIno },
|
|
1415
|
-
requested,
|
|
1416
|
-
);
|
|
1417
|
-
const commonMatches = samePhysicalDirectory(
|
|
1418
|
-
{ dev: transaction.rootDev, ino: transaction.rootIno },
|
|
1419
|
-
common,
|
|
1420
|
-
);
|
|
1421
|
-
if (!worktreeMatches && !commonMatches) return null;
|
|
1422
|
-
if (!commonMatches) {
|
|
1423
|
-
throw new Error(
|
|
1424
|
-
`workspace Git worktree guard belongs to another common repository: `
|
|
1425
|
-
+ `${transaction.record}`,
|
|
1426
|
-
);
|
|
352
|
+
const abs = path.resolve(root, write.path);
|
|
353
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
354
|
+
throw new Error(`unsafe path: ${write.path}`);
|
|
1427
355
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
common.realpath,
|
|
1433
|
-
'amalgm-metadata-apply',
|
|
1434
|
-
`${transaction.id}.target`,
|
|
1435
|
-
),
|
|
1436
|
-
claimed: path.join(
|
|
1437
|
-
common.realpath,
|
|
1438
|
-
'amalgm-metadata-apply',
|
|
1439
|
-
`${transaction.id}.claimed`,
|
|
1440
|
-
),
|
|
1441
|
-
};
|
|
1442
|
-
})
|
|
1443
|
-
.filter(Boolean);
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
function removeGitWorktreeNamespaceTransaction(transaction) {
|
|
1447
|
-
try {
|
|
1448
|
-
fs.unlinkSync(transaction.record);
|
|
1449
|
-
} catch (error) {
|
|
1450
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
1451
|
-
}
|
|
1452
|
-
if (fs.existsSync(path.dirname(transaction.record))) {
|
|
1453
|
-
syncDirectory(path.dirname(transaction.record));
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
function recoverGitWorktreeNamespaceTransactions(worktreeRoot) {
|
|
1458
|
-
for (const transaction of gitWorktreeNamespaceTransactionRecords(worktreeRoot)) {
|
|
1459
|
-
if (guardOwnerIsActive(transaction)) {
|
|
1460
|
-
throw new TransitionError(
|
|
1461
|
-
'recover-git-worktree-guard',
|
|
1462
|
-
transaction.worktree,
|
|
1463
|
-
`guard owner ${transaction.ownerPid} is still active`,
|
|
1464
|
-
);
|
|
1465
|
-
}
|
|
1466
|
-
const result = runNativeFileTransaction(transaction, 'recover');
|
|
1467
|
-
if (result.status !== 'released') {
|
|
1468
|
-
throw new TransitionError(
|
|
1469
|
-
'recover-git-worktree-guard',
|
|
1470
|
-
transaction.root,
|
|
1471
|
-
result.reason || result.status,
|
|
1472
|
-
);
|
|
1473
|
-
}
|
|
1474
|
-
removeGitWorktreeNamespaceTransaction(transaction);
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
function prepareGitWorktreeNamespaceGuard(worktreeRoot) {
|
|
1479
|
-
recoverGitWorktreeNamespaceTransactions(worktreeRoot);
|
|
1480
|
-
const worktree = physicalDirectoryIdentity(worktreeRoot);
|
|
1481
|
-
const common = physicalDirectoryIdentity(gitCommonDirectory(worktree.realpath));
|
|
1482
|
-
const root = common.realpath;
|
|
1483
|
-
const pinned = pinGitWorktreeNamespace(root);
|
|
1484
|
-
const guardOperation = crypto.randomBytes(32).toString('hex');
|
|
1485
|
-
const paths = gitWorktreeNamespacePaths(root, guardOperation, {
|
|
1486
|
-
dev: common.dev,
|
|
1487
|
-
ino: common.ino,
|
|
1488
|
-
});
|
|
1489
|
-
const target = gitMetadataIdentity(Buffer.alloc(0));
|
|
1490
|
-
return {
|
|
1491
|
-
contract: GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT,
|
|
1492
|
-
backend: 'git-worktree-guard',
|
|
1493
|
-
worktree: worktree.realpath,
|
|
1494
|
-
worktreeDev: worktree.dev,
|
|
1495
|
-
worktreeIno: worktree.ino,
|
|
1496
|
-
root,
|
|
1497
|
-
rootDev: common.dev,
|
|
1498
|
-
rootIno: common.ino,
|
|
1499
|
-
path: 'worktrees',
|
|
1500
|
-
id: paths.id,
|
|
1501
|
-
operation: guardOperation,
|
|
1502
|
-
ownerPid: process.pid,
|
|
1503
|
-
ownerStarted: PROCESS_START_IDENTITY,
|
|
1504
|
-
expected: {
|
|
1505
|
-
kind: 'directory',
|
|
1506
|
-
dev: pinned.namespace.dev,
|
|
1507
|
-
ino: pinned.namespace.ino,
|
|
1508
|
-
},
|
|
1509
|
-
target,
|
|
1510
|
-
entries: pinned.entries,
|
|
1511
|
-
pins: pinned.pins,
|
|
1512
|
-
gitRoots: [
|
|
1513
|
-
root,
|
|
1514
|
-
...pinned.pins.slice(1).map((pin) => pin.realpath),
|
|
1515
|
-
].sort(),
|
|
1516
|
-
record: paths.record,
|
|
1517
|
-
prepared: paths.target,
|
|
1518
|
-
claimed: paths.claimed,
|
|
1519
|
-
};
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
async function withGitWorktreeNamespaceGuard(transaction, testHooks, guarded) {
|
|
1523
|
-
try {
|
|
1524
|
-
durable.writeJson(transaction.record, {
|
|
1525
|
-
contract: transaction.contract,
|
|
1526
|
-
backend: transaction.backend,
|
|
1527
|
-
worktree: transaction.worktree,
|
|
1528
|
-
worktreeDev: transaction.worktreeDev,
|
|
1529
|
-
worktreeIno: transaction.worktreeIno,
|
|
1530
|
-
root: transaction.root,
|
|
1531
|
-
rootDev: transaction.rootDev,
|
|
1532
|
-
rootIno: transaction.rootIno,
|
|
1533
|
-
path: transaction.path,
|
|
1534
|
-
id: transaction.id,
|
|
1535
|
-
operation: transaction.operation,
|
|
1536
|
-
ownerPid: transaction.ownerPid,
|
|
1537
|
-
ownerStarted: transaction.ownerStarted,
|
|
1538
|
-
expected: transaction.expected,
|
|
1539
|
-
target: transaction.target,
|
|
1540
|
-
entries: transaction.entries,
|
|
1541
|
-
});
|
|
1542
|
-
durable.writeFile(transaction.prepared, Buffer.alloc(0), 0o644);
|
|
1543
|
-
const result = runNativeFileTransaction(transaction, 'guard');
|
|
1544
|
-
if (result.status !== 'guarded') {
|
|
1545
|
-
throw new TransitionError(
|
|
1546
|
-
'guard-git-worktree-namespace',
|
|
1547
|
-
transaction.root,
|
|
1548
|
-
result.reason || result.status,
|
|
1549
|
-
);
|
|
1550
|
-
}
|
|
1551
|
-
const guardedEntries = physicalGitWorktreeRegistrations(transaction.prepared);
|
|
1552
|
-
if (JSON.stringify(guardedEntries) !== JSON.stringify(transaction.entries)) {
|
|
1553
|
-
throw new TransitionError(
|
|
1554
|
-
'guard-git-worktree-namespace',
|
|
1555
|
-
transaction.root,
|
|
1556
|
-
'Git worktree registrations changed before namespace exclusion',
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
if (typeof testHooks.afterWorktreeNamespaceGuard === 'function') {
|
|
1560
|
-
await testHooks.afterWorktreeNamespaceGuard();
|
|
1561
|
-
}
|
|
1562
|
-
return await guarded(transaction.root);
|
|
1563
|
-
} finally {
|
|
1564
|
-
const released = runNativeFileTransaction(transaction, 'unguard');
|
|
1565
|
-
if (released.status !== 'released') {
|
|
1566
|
-
throw new Error(
|
|
1567
|
-
`Git worktree namespace guard release failed: ${released.reason || released.status}`,
|
|
1568
|
-
);
|
|
1569
|
-
}
|
|
1570
|
-
removeGitWorktreeNamespaceTransaction(transaction);
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
async function withGitHeadGuard(
|
|
1575
|
-
worktreeRoot,
|
|
1576
|
-
changes,
|
|
1577
|
-
testHooks,
|
|
1578
|
-
guarded,
|
|
1579
|
-
) {
|
|
1580
|
-
const namespace = prepareGitWorktreeNamespaceGuard(worktreeRoot);
|
|
1581
|
-
try {
|
|
1582
|
-
const acquire = (index) => (
|
|
1583
|
-
index === namespace.gitRoots.length
|
|
1584
|
-
? (async () => {
|
|
1585
|
-
if (typeof testHooks.afterHeadGuard === 'function') {
|
|
1586
|
-
await testHooks.afterHeadGuard();
|
|
1587
|
-
}
|
|
1588
|
-
return withGitWorktreeNamespaceGuard(namespace, testHooks, guarded);
|
|
1589
|
-
})()
|
|
1590
|
-
: withOneGitHeadGuard(
|
|
1591
|
-
namespace.gitRoots[index],
|
|
1592
|
-
worktreeRoot,
|
|
1593
|
-
changes,
|
|
1594
|
-
() => acquire(index + 1),
|
|
1595
|
-
)
|
|
1596
|
-
);
|
|
1597
|
-
return await acquire(0);
|
|
1598
|
-
} finally {
|
|
1599
|
-
closePhysicalDirectoryPins(namespace.pins);
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function gitMetadataIdentity(content) {
|
|
1604
|
-
if (content === null) return null;
|
|
1605
|
-
if (!Buffer.isBuffer(content)) throw new Error('Git metadata has no exact bytes');
|
|
1606
|
-
return {
|
|
1607
|
-
kind: 'file',
|
|
1608
|
-
exec: false,
|
|
1609
|
-
bytes: content.length,
|
|
1610
|
-
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
1611
|
-
};
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
function gitMetadataTransactionPathsForRoot(root, operation, component, rootStat) {
|
|
1615
|
-
if (!/^[0-9a-f]{64}$/.test(operation || '')) {
|
|
1616
|
-
throw new Error('Git metadata transaction has no operation identity');
|
|
1617
|
-
}
|
|
1618
|
-
if (component !== 'index' && component !== 'HEAD') {
|
|
1619
|
-
throw new Error(`unsupported Git metadata component: ${component}`);
|
|
1620
|
-
}
|
|
1621
|
-
const directory = gitMetadataTransactionDirectoryForRoot(root);
|
|
1622
|
-
ensureDurableDirectory(directory);
|
|
1623
|
-
const id = crypto.createHash('sha256')
|
|
1624
|
-
.update(
|
|
1625
|
-
`${GIT_METADATA_TRANSACTION_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
|
|
1626
|
-
+ `\0${operation}\0${component}`,
|
|
1627
|
-
)
|
|
1628
|
-
.digest('hex');
|
|
1629
|
-
return {
|
|
1630
|
-
id,
|
|
1631
|
-
directory,
|
|
1632
|
-
record: path.join(directory, `${id}.json`),
|
|
1633
|
-
target: path.join(directory, `${id}.target`),
|
|
1634
|
-
claimed: path.join(directory, `${id}.claimed`),
|
|
1635
|
-
};
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
function gitMetadataTransactionPaths(worktreeRoot, operation, component, rootStat) {
|
|
1639
|
-
return gitMetadataTransactionPathsForRoot(
|
|
1640
|
-
gitDirectory(worktreeRoot),
|
|
1641
|
-
operation,
|
|
1642
|
-
component,
|
|
1643
|
-
rootStat,
|
|
1644
|
-
);
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
function readGitMetadataTransaction(record) {
|
|
1648
|
-
try {
|
|
1649
|
-
const value = JSON.parse(fs.readFileSync(record, 'utf8'));
|
|
1650
|
-
if (
|
|
1651
|
-
value?.contract !== GIT_METADATA_TRANSACTION_CONTRACT
|
|
1652
|
-
|| (value.backend !== 'git-lock' && value.backend !== 'git-guard')
|
|
1653
|
-
|| typeof value.root !== 'string'
|
|
1654
|
-
|| !/^\d+$/.test(value.rootDev)
|
|
1655
|
-
|| !/^\d+$/.test(value.rootIno)
|
|
1656
|
-
|| (value.path !== 'index' && value.path !== 'HEAD')
|
|
1657
|
-
|| typeof value.id !== 'string'
|
|
1658
|
-
|| value.id !== path.basename(record, '.json')
|
|
1659
|
-
|| !/^[a-f0-9]{64}$/.test(value.id)
|
|
1660
|
-
|| !/^[a-f0-9]{64}$/.test(value.operation || '')
|
|
1661
|
-
|| (
|
|
1662
|
-
value.backend === 'git-guard'
|
|
1663
|
-
&& (
|
|
1664
|
-
!Number.isSafeInteger(value.ownerPid)
|
|
1665
|
-
|| value.ownerPid < 1
|
|
1666
|
-
|| typeof value.ownerStarted !== 'string'
|
|
1667
|
-
|| !value.ownerStarted
|
|
1668
|
-
)
|
|
1669
|
-
)
|
|
1670
|
-
|| (value.expected !== null && typeof value.expected !== 'object')
|
|
1671
|
-
|| typeof value.target !== 'object'
|
|
1672
|
-
) throw new Error('invalid Git metadata transaction');
|
|
1673
|
-
return {
|
|
1674
|
-
...value,
|
|
1675
|
-
record,
|
|
1676
|
-
prepared: path.join(path.dirname(record), `${value.id}.target`),
|
|
1677
|
-
claimed: path.join(path.dirname(record), `${value.id}.claimed`),
|
|
1678
|
-
};
|
|
1679
|
-
} catch (error) {
|
|
1680
|
-
throw new Error(`invalid workspace Git metadata transaction ${record}: ${error.message}`);
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
function gitMetadataTransactionRecordsAtRoot(rootInput, operation = null, component = null) {
|
|
1685
|
-
const root = physicalDirectoryIdentity(rootInput);
|
|
1686
|
-
const directory = gitMetadataTransactionDirectoryForRoot(root.realpath);
|
|
1687
|
-
if (!fs.existsSync(directory)) return [];
|
|
1688
|
-
return fs.readdirSync(directory)
|
|
1689
|
-
.filter((name) => name.endsWith('.json'))
|
|
1690
|
-
.sort()
|
|
1691
|
-
.map((name) => readGitMetadataTransaction(path.join(directory, name)))
|
|
1692
|
-
.map((transaction) => {
|
|
1693
|
-
if (!samePhysicalDirectory(
|
|
1694
|
-
{ dev: transaction.rootDev, ino: transaction.rootIno },
|
|
1695
|
-
root,
|
|
1696
|
-
)) {
|
|
1697
|
-
throw new Error(`Git metadata transaction escaped its worktree: ${transaction.record}`);
|
|
356
|
+
if (write.content === null) {
|
|
357
|
+
fs.rmSync(abs, { force: true });
|
|
358
|
+
removed.push(write.path);
|
|
359
|
+
continue;
|
|
1698
360
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
return gitMetadataTransactionRecordsAtRoot(
|
|
1707
|
-
gitDirectory(worktreeRoot),
|
|
1708
|
-
operation,
|
|
1709
|
-
component,
|
|
1710
|
-
);
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
function sameGitMetadataTransaction(left, right) {
|
|
1714
|
-
return (
|
|
1715
|
-
left.contract === right.contract
|
|
1716
|
-
&& left.backend === right.backend
|
|
1717
|
-
&& left.rootDev === right.rootDev
|
|
1718
|
-
&& left.rootIno === right.rootIno
|
|
1719
|
-
&& left.path === right.path
|
|
1720
|
-
&& left.id === right.id
|
|
1721
|
-
&& left.operation === right.operation
|
|
1722
|
-
&& JSON.stringify(left.expected) === JSON.stringify(right.expected)
|
|
1723
|
-
&& JSON.stringify(left.target) === JSON.stringify(right.target)
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
function removeGitMetadataTransaction(transaction) {
|
|
1728
|
-
for (const file of [transaction.record, transaction.prepared, transaction.claimed]) {
|
|
1729
|
-
try {
|
|
1730
|
-
fs.unlinkSync(file);
|
|
1731
|
-
} catch (error) {
|
|
1732
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
if (fs.existsSync(path.dirname(transaction.record))) {
|
|
1736
|
-
syncDirectory(path.dirname(transaction.record));
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/**
|
|
1741
|
-
* Apply one exact index or HEAD byte transition through Git's canonical lock.
|
|
1742
|
-
* The prepared bytes stay in the per-worktree Git directory and are hard
|
|
1743
|
-
* linked to index.lock/HEAD.lock by the native helper, so even very large
|
|
1744
|
-
* indexes never travel through JSON.
|
|
1745
|
-
*/
|
|
1746
|
-
function applyGitMetadataTransaction(
|
|
1747
|
-
worktreeRoot,
|
|
1748
|
-
component,
|
|
1749
|
-
expected,
|
|
1750
|
-
target,
|
|
1751
|
-
operation,
|
|
1752
|
-
testHooks = {},
|
|
1753
|
-
) {
|
|
1754
|
-
if (expected !== null && !Buffer.isBuffer(expected)) {
|
|
1755
|
-
throw new Error('Git metadata expected value has no exact bytes');
|
|
1756
|
-
}
|
|
1757
|
-
if (!Buffer.isBuffer(target)) throw new Error('Git metadata target has no exact bytes');
|
|
1758
|
-
const root = fs.realpathSync(gitDirectory(worktreeRoot));
|
|
1759
|
-
const rootStat = fs.statSync(root, { bigint: true });
|
|
1760
|
-
const paths = gitMetadataTransactionPaths(worktreeRoot, operation, component, rootStat);
|
|
1761
|
-
const transaction = {
|
|
1762
|
-
contract: GIT_METADATA_TRANSACTION_CONTRACT,
|
|
1763
|
-
backend: 'git-lock',
|
|
1764
|
-
root,
|
|
1765
|
-
rootDev: rootStat.dev.toString(),
|
|
1766
|
-
rootIno: rootStat.ino.toString(),
|
|
1767
|
-
path: component,
|
|
1768
|
-
id: paths.id,
|
|
1769
|
-
operation,
|
|
1770
|
-
expected: gitMetadataIdentity(expected),
|
|
1771
|
-
target: gitMetadataIdentity(target),
|
|
1772
|
-
record: paths.record,
|
|
1773
|
-
prepared: paths.target,
|
|
1774
|
-
claimed: paths.claimed,
|
|
1775
|
-
};
|
|
1776
|
-
|
|
1777
|
-
if (fs.existsSync(paths.record)) {
|
|
1778
|
-
const retained = readGitMetadataTransaction(paths.record);
|
|
1779
|
-
if (!sameGitMetadataTransaction(retained, transaction)) {
|
|
1780
|
-
throw new Error(`Git metadata transaction identity changed: ${component}`);
|
|
1781
|
-
}
|
|
1782
|
-
const recovered = runNativeFileTransaction(retained, 'recover');
|
|
1783
|
-
if (recovered.status === 'applied') return;
|
|
1784
|
-
if (recovered.status === 'pending') {
|
|
1785
|
-
throw new TransitionError(
|
|
1786
|
-
'apply-git-metadata',
|
|
1787
|
-
worktreeRoot,
|
|
1788
|
-
`${component}: ${recovered.reason || 'ownership is pending'}`,
|
|
1789
|
-
);
|
|
1790
|
-
}
|
|
1791
|
-
removeGitMetadataTransaction(retained);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
durable.writeJson(paths.record, {
|
|
1795
|
-
contract: transaction.contract,
|
|
1796
|
-
backend: transaction.backend,
|
|
1797
|
-
root: transaction.root,
|
|
1798
|
-
rootDev: transaction.rootDev,
|
|
1799
|
-
rootIno: transaction.rootIno,
|
|
1800
|
-
path: transaction.path,
|
|
1801
|
-
id: transaction.id,
|
|
1802
|
-
operation: transaction.operation,
|
|
1803
|
-
expected: transaction.expected,
|
|
1804
|
-
target: transaction.target,
|
|
1805
|
-
});
|
|
1806
|
-
durable.writeFile(paths.target, target, 0o644);
|
|
1807
|
-
const result = runNativeFileTransaction(transaction, 'apply', null, {
|
|
1808
|
-
crashBeforeInstall: testHooks.crashBeforeInstall === true,
|
|
1809
|
-
crashAfterInstall: testHooks.crashAfterInstall === true,
|
|
1810
|
-
});
|
|
1811
|
-
if (result.status === 'pending') {
|
|
1812
|
-
throw new TransitionError(
|
|
1813
|
-
'apply-git-metadata',
|
|
1814
|
-
worktreeRoot,
|
|
1815
|
-
`${component}: ${result.reason || 'ownership is pending'}`,
|
|
1816
|
-
);
|
|
1817
|
-
}
|
|
1818
|
-
if (result.status === 'changed') {
|
|
1819
|
-
removeGitMetadataTransaction(transaction);
|
|
1820
|
-
throw new TransitionError(
|
|
1821
|
-
'apply-git-metadata',
|
|
1822
|
-
worktreeRoot,
|
|
1823
|
-
`${component} changed before ordered apply`,
|
|
1824
|
-
);
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
function recoverGitMetadataTransactionsAtRoot(root, label) {
|
|
1829
|
-
for (const transaction of gitMetadataTransactionRecordsAtRoot(root)) {
|
|
1830
|
-
if (transaction.backend === 'git-guard' && guardOwnerIsActive(transaction)) {
|
|
1831
|
-
throw new TransitionError(
|
|
1832
|
-
'recover-git-head-guard',
|
|
1833
|
-
label,
|
|
1834
|
-
`guard owner ${transaction.ownerPid} is still active`,
|
|
1835
|
-
);
|
|
1836
|
-
}
|
|
1837
|
-
const result = runNativeFileTransaction(transaction, 'recover');
|
|
1838
|
-
if (transaction.backend === 'git-guard') {
|
|
1839
|
-
if (result.status !== 'released') {
|
|
1840
|
-
throw new TransitionError(
|
|
1841
|
-
'recover-git-head-guard',
|
|
1842
|
-
label,
|
|
1843
|
-
result.reason || result.status,
|
|
1844
|
-
);
|
|
361
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
362
|
+
if (write.mode === '120000') {
|
|
363
|
+
fs.rmSync(abs, { force: true });
|
|
364
|
+
fs.symlinkSync(write.content.toString('utf8'), abs);
|
|
365
|
+
} else {
|
|
366
|
+
fs.writeFileSync(abs, write.content);
|
|
367
|
+
fs.chmodSync(abs, write.mode === '100755' ? 0o755 : 0o644);
|
|
1845
368
|
}
|
|
1846
|
-
|
|
1847
|
-
continue;
|
|
369
|
+
written.push(write.path);
|
|
1848
370
|
}
|
|
1849
|
-
|
|
1850
|
-
throw new TransitionError(
|
|
1851
|
-
'recover-git-metadata',
|
|
1852
|
-
label,
|
|
1853
|
-
`${transaction.path}: ${result.reason || 'ownership is pending'}`,
|
|
1854
|
-
);
|
|
1855
|
-
}
|
|
1856
|
-
if (result.status === 'changed') removeGitMetadataTransaction(transaction);
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
function gitHeadRoots(worktreeRoot) {
|
|
1861
|
-
const common = gitCommonDirectoryFromMarker(worktreeRoot);
|
|
1862
|
-
const registrations = path.join(common, 'worktrees');
|
|
1863
|
-
let names = [];
|
|
1864
|
-
try {
|
|
1865
|
-
names = fs.readdirSync(registrations).sort();
|
|
371
|
+
journal({ action: 'merge-files', worktree: root, label, written, removed, ok: true });
|
|
1866
372
|
} catch (error) {
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
];
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
function recoverGitMetadataTransactions(worktreeRoot) {
|
|
1876
|
-
recoverGitWorktreeNamespaceTransactions(worktreeRoot);
|
|
1877
|
-
for (const root of gitHeadRoots(worktreeRoot)) {
|
|
1878
|
-
try {
|
|
1879
|
-
recoverGitMetadataTransactionsAtRoot(root, worktreeRoot);
|
|
1880
|
-
} catch (error) {
|
|
1881
|
-
if (error?.code === 'ENOENT') continue;
|
|
1882
|
-
throw error;
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
function gitMetadataReceiptCrossed(worktreeRoot, operation, component) {
|
|
1888
|
-
recoverGitMetadataTransactions(worktreeRoot);
|
|
1889
|
-
const records = gitMetadataTransactionRecords(worktreeRoot, operation, component);
|
|
1890
|
-
if (records.length > 1) {
|
|
1891
|
-
throw new Error(`multiple Git metadata receipts exist for ${component}`);
|
|
1892
|
-
}
|
|
1893
|
-
if (records.length === 0) return false;
|
|
1894
|
-
const result = runNativeFileTransaction(records[0], 'receipt');
|
|
1895
|
-
if (result.status === 'crossed') return true;
|
|
1896
|
-
if (result.status === 'not-crossed') return false;
|
|
1897
|
-
throw new Error(
|
|
1898
|
-
`Git metadata receipt is unresolved: ${component}: ${result.reason || result.status}`,
|
|
1899
|
-
);
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
function releaseGitMetadataReceipts(worktreeRoot, operation, component = null) {
|
|
1903
|
-
for (const transaction of gitMetadataTransactionRecords(worktreeRoot, operation, component)) {
|
|
1904
|
-
const result = runNativeFileTransaction(transaction, 'release');
|
|
1905
|
-
if (result.status === 'pending') {
|
|
1906
|
-
throw new Error(
|
|
1907
|
-
`Git metadata receipt cannot be released: ${transaction.path}: `
|
|
1908
|
-
+ `${result.reason || 'pending'}`,
|
|
1909
|
-
);
|
|
1910
|
-
}
|
|
1911
|
-
if (result.status !== 'released') {
|
|
1912
|
-
throw new Error(
|
|
1913
|
-
`Git metadata receipt release failed: ${transaction.path}: ${result.status}`,
|
|
1914
|
-
);
|
|
1915
|
-
}
|
|
1916
|
-
removeGitMetadataTransaction(transaction);
|
|
373
|
+
journal({
|
|
374
|
+
action: 'merge-files', worktree: root, label, written, removed, ok: false,
|
|
375
|
+
error: String(error.message).slice(0, 500),
|
|
376
|
+
});
|
|
377
|
+
throw new TransitionError('merge-files', root, String(error.message));
|
|
1917
378
|
}
|
|
1918
379
|
}
|
|
1919
380
|
|
|
1920
381
|
module.exports = {
|
|
1921
382
|
TransitionError,
|
|
1922
|
-
applyGitMetadataTransaction,
|
|
1923
383
|
applyIndexPatch,
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
384
|
+
applyMergedFiles,
|
|
385
|
+
applyPatch,
|
|
386
|
+
checkoutInitialCard,
|
|
1927
387
|
createBundle,
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
388
|
+
deleteBranchRef,
|
|
389
|
+
fastForwardTo,
|
|
390
|
+
fetchBranch,
|
|
391
|
+
forceMoveToCard,
|
|
392
|
+
holdInitialFiles,
|
|
1931
393
|
importBundle,
|
|
1932
|
-
initializeHeadToCard,
|
|
1933
394
|
initializeRepo,
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
releaseFileReceipts,
|
|
1941
|
-
releaseGitMetadataReceipts,
|
|
1942
|
-
updateBranchRefs,
|
|
395
|
+
moveToCard,
|
|
396
|
+
parkCommit,
|
|
397
|
+
pruneParkedCommits,
|
|
398
|
+
restoreInitialFiles,
|
|
399
|
+
setBranchRef,
|
|
400
|
+
setIndexTree,
|
|
1943
401
|
};
|