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
|
@@ -1,1446 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const test = require('node:test');
|
|
4
|
-
const assert = require('node:assert/strict');
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const os = require('os');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const { execFile, spawn } = require('child_process');
|
|
9
|
-
const { promisify } = require('util');
|
|
10
|
-
|
|
11
|
-
const execFileAsync = promisify(execFile);
|
|
12
|
-
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-transition-test-'));
|
|
13
|
-
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
14
|
-
|
|
15
|
-
const {
|
|
16
|
-
TransitionError,
|
|
17
|
-
applyFiles,
|
|
18
|
-
applyGitMetadataTransaction,
|
|
19
|
-
branchReceiptCrossed,
|
|
20
|
-
crossedFileReceiptPaths,
|
|
21
|
-
ensureEmptyIndex,
|
|
22
|
-
gitMetadataReceiptCrossed,
|
|
23
|
-
moveHeadToCard,
|
|
24
|
-
recoverFileTransactions,
|
|
25
|
-
recoverGitMetadataTransactions,
|
|
26
|
-
releaseBranchReceipt,
|
|
27
|
-
releaseFileReceipts,
|
|
28
|
-
releaseGitMetadataReceipts,
|
|
29
|
-
updateBranchRefs,
|
|
30
|
-
} = require('../workspace/transition');
|
|
31
|
-
const { captureCheckpoint, captureWorktreeDiff } = require('../workspace/git');
|
|
32
|
-
|
|
33
|
-
test.after(() => {
|
|
34
|
-
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
async function git(cwd, ...args) {
|
|
38
|
-
const { stdout } = await execFileAsync('git', [
|
|
39
|
-
'-c', 'user.email=test@amalgm.test',
|
|
40
|
-
'-c', 'user.name=Amalgm Test',
|
|
41
|
-
'-c', 'commit.gpgsign=false',
|
|
42
|
-
...args,
|
|
43
|
-
], {
|
|
44
|
-
cwd,
|
|
45
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
|
|
46
|
-
});
|
|
47
|
-
return stdout.trim();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function makeRepo(name) {
|
|
51
|
-
const repo = path.join(tempRoot, name);
|
|
52
|
-
fs.mkdirSync(repo, { recursive: true });
|
|
53
|
-
await git(repo, 'init', '-b', 'main');
|
|
54
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'one\n');
|
|
55
|
-
await git(repo, 'add', '.');
|
|
56
|
-
await git(repo, 'commit', '-m', 'one');
|
|
57
|
-
const first = await git(repo, 'rev-parse', 'HEAD');
|
|
58
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'two\n');
|
|
59
|
-
await git(repo, 'commit', '-am', 'two');
|
|
60
|
-
const second = await git(repo, 'rev-parse', 'HEAD');
|
|
61
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'three\n');
|
|
62
|
-
await git(repo, 'commit', '-am', 'three');
|
|
63
|
-
const third = await git(repo, 'rev-parse', 'HEAD');
|
|
64
|
-
return { repo, first, second, third };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function branch(repo, name) {
|
|
68
|
-
try {
|
|
69
|
-
return await git(repo, 'rev-parse', '--verify', `refs/heads/${name}`);
|
|
70
|
-
} catch {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function gitPath(repo, relativePath) {
|
|
76
|
-
const value = await git(repo, 'rev-parse', '--git-path', relativePath);
|
|
77
|
-
return path.isAbsolute(value) ? value : path.resolve(repo, value);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function replaceThroughLock(file, content) {
|
|
81
|
-
const lock = `${file}.lock`;
|
|
82
|
-
fs.writeFileSync(lock, content);
|
|
83
|
-
fs.renameSync(lock, file);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function delayedNode(source) {
|
|
87
|
-
const child = spawn(process.execPath, ['-e', source], {
|
|
88
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
-
});
|
|
90
|
-
return {
|
|
91
|
-
child,
|
|
92
|
-
done: new Promise((resolve, reject) => {
|
|
93
|
-
let stderr = '';
|
|
94
|
-
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
95
|
-
child.once('error', reject);
|
|
96
|
-
child.once('exit', (code, signal) => {
|
|
97
|
-
if (code === 0) resolve();
|
|
98
|
-
else reject(new Error(`delayed test writer exited ${code || signal}: ${stderr}`));
|
|
99
|
-
});
|
|
100
|
-
}),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
test('branch creates, updates, and deletes land in one expected-old transaction', async () => {
|
|
105
|
-
const { repo, first, second, third } = await makeRepo('atomic');
|
|
106
|
-
await git(repo, 'branch', 'move-me', first);
|
|
107
|
-
await git(repo, 'branch', 'delete-me', second);
|
|
108
|
-
|
|
109
|
-
await updateBranchRefs(repo, [
|
|
110
|
-
{ branch: 'create-me', expectedOid: null, oid: third },
|
|
111
|
-
{ branch: 'move-me', expectedOid: first, oid: second },
|
|
112
|
-
{ branch: 'delete-me', expectedOid: second, oid: null },
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
|
-
assert.equal(await branch(repo, 'create-me'), third);
|
|
116
|
-
assert.equal(await branch(repo, 'move-me'), second);
|
|
117
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('branch deletion excludes a concurrent HEAD attachment with Git own lock', async () => {
|
|
121
|
-
const { repo, first } = await makeRepo('branch-delete-head-guard');
|
|
122
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
123
|
-
const transitionFile = path.resolve(__dirname, '../workspace/transition.js');
|
|
124
|
-
await updateBranchRefs(
|
|
125
|
-
repo,
|
|
126
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
127
|
-
null,
|
|
128
|
-
{
|
|
129
|
-
afterHeadGuard: async () => {
|
|
130
|
-
const recoverSource = `
|
|
131
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
132
|
-
require(${JSON.stringify(transitionFile)})
|
|
133
|
-
.recoverGitMetadataTransactions(${JSON.stringify(repo)});
|
|
134
|
-
`;
|
|
135
|
-
await assert.rejects(
|
|
136
|
-
execFileAsync(process.execPath, ['-e', recoverSource]),
|
|
137
|
-
(error) => /guard owner \d+ is still active/.test(String(error.stderr)),
|
|
138
|
-
);
|
|
139
|
-
await assert.rejects(
|
|
140
|
-
git(repo, 'symbolic-ref', 'HEAD', 'refs/heads/delete-me'),
|
|
141
|
-
/HEAD\.lock|cannot lock ref 'HEAD'/,
|
|
142
|
-
);
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
);
|
|
146
|
-
assert.equal(await git(repo, 'symbolic-ref', '--short', 'HEAD'), 'main');
|
|
147
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
148
|
-
assert.equal(fs.existsSync(`${await gitPath(repo, 'HEAD')}.lock`), false);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test('branch deletion refuses the branch currently named by HEAD', async () => {
|
|
152
|
-
const { repo, third } = await makeRepo('branch-delete-current-head');
|
|
153
|
-
await assert.rejects(
|
|
154
|
-
updateBranchRefs(repo, [
|
|
155
|
-
{ branch: 'main', expectedOid: third, oid: null },
|
|
156
|
-
]),
|
|
157
|
-
(error) => (
|
|
158
|
-
error instanceof TransitionError
|
|
159
|
-
&& error.action === 'guard-branch-delete'
|
|
160
|
-
),
|
|
161
|
-
);
|
|
162
|
-
assert.equal(await git(repo, 'symbolic-ref', '--short', 'HEAD'), 'main');
|
|
163
|
-
assert.equal(await branch(repo, 'main'), third);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('branch deletion refuses a branch checked out in a linked worktree', async () => {
|
|
167
|
-
const { repo, first } = await makeRepo('branch-delete-linked-head');
|
|
168
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
169
|
-
const linked = path.join(tempRoot, 'branch-delete-linked-head-peer');
|
|
170
|
-
await git(repo, 'worktree', 'add', linked, 'delete-me');
|
|
171
|
-
await assert.rejects(
|
|
172
|
-
updateBranchRefs(repo, [
|
|
173
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
174
|
-
]),
|
|
175
|
-
(error) => (
|
|
176
|
-
error instanceof TransitionError
|
|
177
|
-
&& error.action === 'guard-branch-delete'
|
|
178
|
-
),
|
|
179
|
-
);
|
|
180
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'delete-me');
|
|
181
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test('linked worktree HEAD cannot enter a branch deletion transaction', async () => {
|
|
185
|
-
const { repo, first } = await makeRepo('branch-delete-linked-race');
|
|
186
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
187
|
-
const linked = path.join(tempRoot, 'branch-delete-linked-race-peer');
|
|
188
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', linked, first);
|
|
189
|
-
await updateBranchRefs(
|
|
190
|
-
repo,
|
|
191
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
192
|
-
null,
|
|
193
|
-
{
|
|
194
|
-
afterHeadGuard: async () => {
|
|
195
|
-
await assert.rejects(
|
|
196
|
-
git(linked, 'symbolic-ref', 'HEAD', 'refs/heads/delete-me'),
|
|
197
|
-
/HEAD\.lock|cannot lock ref 'HEAD'/,
|
|
198
|
-
);
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
);
|
|
202
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'peer');
|
|
203
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
204
|
-
assert.equal(fs.existsSync(`${await gitPath(linked, 'HEAD')}.lock`), false);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test('a temporarily missing linked checkout still has its registered HEAD guarded', async () => {
|
|
208
|
-
const { repo, first } = await makeRepo('branch-delete-missing-linked-race');
|
|
209
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
210
|
-
const linked = path.join(tempRoot, 'branch-delete-missing-linked-race-peer');
|
|
211
|
-
const displaced = `${linked}-displaced`;
|
|
212
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', linked, first);
|
|
213
|
-
fs.renameSync(linked, displaced);
|
|
214
|
-
|
|
215
|
-
await updateBranchRefs(
|
|
216
|
-
repo,
|
|
217
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
218
|
-
null,
|
|
219
|
-
{
|
|
220
|
-
afterHeadGuard: async () => {
|
|
221
|
-
fs.renameSync(displaced, linked);
|
|
222
|
-
await assert.rejects(
|
|
223
|
-
git(linked, 'symbolic-ref', 'HEAD', 'refs/heads/delete-me'),
|
|
224
|
-
/HEAD\.lock|cannot lock ref 'HEAD'/,
|
|
225
|
-
);
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'peer');
|
|
231
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('a newly registered worktree invalidates the sealed deletion namespace', async () => {
|
|
235
|
-
const { repo, first } = await makeRepo('branch-delete-new-worktree-race');
|
|
236
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
237
|
-
const linked = path.join(tempRoot, 'branch-delete-new-worktree-race-peer');
|
|
238
|
-
await assert.rejects(
|
|
239
|
-
updateBranchRefs(
|
|
240
|
-
repo,
|
|
241
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
242
|
-
null,
|
|
243
|
-
{
|
|
244
|
-
afterHeadGuard: () => git(repo, 'worktree', 'add', linked, 'delete-me'),
|
|
245
|
-
},
|
|
246
|
-
),
|
|
247
|
-
(error) => (
|
|
248
|
-
error instanceof TransitionError
|
|
249
|
-
&& error.action === 'guard-git-worktree-namespace'
|
|
250
|
-
),
|
|
251
|
-
);
|
|
252
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'delete-me');
|
|
253
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
254
|
-
assert.equal(fs.statSync(await gitPath(repo, 'worktrees')).isDirectory(), true);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test('remove and re-add under one worktree name invalidates deletion', async () => {
|
|
258
|
-
const { repo, first } = await makeRepo('branch-delete-worktree-registration-replace');
|
|
259
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
260
|
-
const peer = path.join(tempRoot, 'branch-delete-worktree-registration-replace-peer');
|
|
261
|
-
const stable = path.join(tempRoot, 'branch-delete-worktree-registration-replace-stable');
|
|
262
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', peer, first);
|
|
263
|
-
await git(repo, 'worktree', 'add', '-b', 'stable', stable, first);
|
|
264
|
-
let enteredGuardedTransaction = false;
|
|
265
|
-
|
|
266
|
-
await assert.rejects(
|
|
267
|
-
updateBranchRefs(
|
|
268
|
-
repo,
|
|
269
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
270
|
-
null,
|
|
271
|
-
{
|
|
272
|
-
afterHeadGuard: async () => {
|
|
273
|
-
await git(repo, 'worktree', 'remove', '--force', peer);
|
|
274
|
-
await git(repo, 'worktree', 'add', peer, 'delete-me');
|
|
275
|
-
},
|
|
276
|
-
afterWorktreeNamespaceGuard: () => {
|
|
277
|
-
enteredGuardedTransaction = true;
|
|
278
|
-
},
|
|
279
|
-
},
|
|
280
|
-
),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
assert.equal(enteredGuardedTransaction, false);
|
|
284
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
285
|
-
assert.equal(await git(peer, 'symbolic-ref', '--short', 'HEAD'), 'delete-me');
|
|
286
|
-
assert.equal(fs.statSync(await gitPath(repo, 'worktrees')).isDirectory(), true);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test('same-membership worktree namespace replacement invalidates deletion', async () => {
|
|
290
|
-
const { repo, first } = await makeRepo('branch-delete-worktree-namespace-replace');
|
|
291
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
292
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
293
|
-
const displaced = `${worktrees}.before-test`;
|
|
294
|
-
await assert.rejects(
|
|
295
|
-
updateBranchRefs(
|
|
296
|
-
repo,
|
|
297
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
298
|
-
null,
|
|
299
|
-
{
|
|
300
|
-
afterHeadGuard: () => {
|
|
301
|
-
fs.renameSync(worktrees, displaced);
|
|
302
|
-
fs.mkdirSync(worktrees);
|
|
303
|
-
},
|
|
304
|
-
},
|
|
305
|
-
),
|
|
306
|
-
(error) => (
|
|
307
|
-
error instanceof TransitionError
|
|
308
|
-
&& error.action === 'guard-git-worktree-namespace'
|
|
309
|
-
),
|
|
310
|
-
);
|
|
311
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
312
|
-
fs.rmdirSync(worktrees);
|
|
313
|
-
fs.renameSync(displaced, worktrees);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
test('the guarded worktree namespace rejects a new linked worktree', async () => {
|
|
317
|
-
const { repo, first } = await makeRepo('branch-delete-worktree-namespace');
|
|
318
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
319
|
-
const linked = path.join(tempRoot, 'branch-delete-worktree-namespace-peer');
|
|
320
|
-
const transitionFile = path.resolve(__dirname, '../workspace/transition.js');
|
|
321
|
-
await updateBranchRefs(
|
|
322
|
-
repo,
|
|
323
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
324
|
-
null,
|
|
325
|
-
{
|
|
326
|
-
afterWorktreeNamespaceGuard: async () => {
|
|
327
|
-
const recoverSource = `
|
|
328
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
329
|
-
require(${JSON.stringify(transitionFile)})
|
|
330
|
-
.recoverGitMetadataTransactions(${JSON.stringify(repo)});
|
|
331
|
-
`;
|
|
332
|
-
await assert.rejects(
|
|
333
|
-
execFileAsync(process.execPath, ['-e', recoverSource]),
|
|
334
|
-
(error) => /guard owner \d+ is still active/.test(String(error.stderr)),
|
|
335
|
-
);
|
|
336
|
-
await assert.rejects(
|
|
337
|
-
git(repo, 'worktree', 'add', linked, 'delete-me'),
|
|
338
|
-
/worktrees|Not a directory|not a directory/,
|
|
339
|
-
);
|
|
340
|
-
},
|
|
341
|
-
},
|
|
342
|
-
);
|
|
343
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
344
|
-
assert.equal(fs.existsSync(linked), false);
|
|
345
|
-
assert.equal(fs.statSync(await gitPath(repo, 'worktrees')).isDirectory(), true);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
test('a mixed checked-ref and deletion crash leaves exact per-branch receipts', async () => {
|
|
349
|
-
const { repo, first, second, third } = await makeRepo('branch-delete-split-recovery');
|
|
350
|
-
const operation = 'e'.repeat(64);
|
|
351
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
352
|
-
await assert.rejects(
|
|
353
|
-
updateBranchRefs(
|
|
354
|
-
repo,
|
|
355
|
-
[
|
|
356
|
-
{ branch: 'main', expectedOid: third, oid: second },
|
|
357
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
358
|
-
],
|
|
359
|
-
operation,
|
|
360
|
-
{
|
|
361
|
-
afterNonDeletionBatch: () => {
|
|
362
|
-
throw new Error('simulated process death between branch batches');
|
|
363
|
-
},
|
|
364
|
-
},
|
|
365
|
-
),
|
|
366
|
-
/simulated process death/,
|
|
367
|
-
);
|
|
368
|
-
assert.equal(await branch(repo, 'main'), second);
|
|
369
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
370
|
-
assert.equal(await branchReceiptCrossed(repo, operation, 'main'), true);
|
|
371
|
-
assert.equal(await branchReceiptCrossed(repo, operation, 'delete-me'), false);
|
|
372
|
-
|
|
373
|
-
await updateBranchRefs(repo, [
|
|
374
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
375
|
-
], operation);
|
|
376
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
377
|
-
assert.equal(await branchReceiptCrossed(repo, operation, 'delete-me'), true);
|
|
378
|
-
await releaseBranchReceipt(repo, operation);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test('restart removes an owned HEAD guard before retrying branch deletion', async () => {
|
|
382
|
-
const { repo, first } = await makeRepo('branch-delete-guard-recovery');
|
|
383
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
384
|
-
const transitionFile = path.resolve(
|
|
385
|
-
__dirname,
|
|
386
|
-
'../workspace/transition.js',
|
|
387
|
-
);
|
|
388
|
-
const source = `
|
|
389
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
390
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
391
|
-
updateBranchRefs(
|
|
392
|
-
${JSON.stringify(repo)},
|
|
393
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
394
|
-
${JSON.stringify('d'.repeat(64))},
|
|
395
|
-
{ afterHeadGuard: () => process.exit(86) },
|
|
396
|
-
);
|
|
397
|
-
`;
|
|
398
|
-
await assert.rejects(
|
|
399
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
400
|
-
(error) => error.code === 86,
|
|
401
|
-
);
|
|
402
|
-
const headLock = `${await gitPath(repo, 'HEAD')}.lock`;
|
|
403
|
-
assert.equal(fs.existsSync(headLock), true);
|
|
404
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
405
|
-
|
|
406
|
-
recoverGitMetadataTransactions(repo);
|
|
407
|
-
assert.equal(fs.existsSync(headLock), false);
|
|
408
|
-
await updateBranchRefs(repo, [
|
|
409
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
410
|
-
]);
|
|
411
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test('restart restores an exchanged worktree namespace before repo observation', async () => {
|
|
415
|
-
const { repo, first } = await makeRepo('branch-delete-namespace-recovery');
|
|
416
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
417
|
-
const transitionFile = path.resolve(
|
|
418
|
-
__dirname,
|
|
419
|
-
'../workspace/transition.js',
|
|
420
|
-
);
|
|
421
|
-
const source = `
|
|
422
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
423
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
424
|
-
updateBranchRefs(
|
|
425
|
-
${JSON.stringify(repo)},
|
|
426
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
427
|
-
${JSON.stringify('f'.repeat(64))},
|
|
428
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
429
|
-
);
|
|
430
|
-
`;
|
|
431
|
-
await assert.rejects(
|
|
432
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
433
|
-
(error) => error.code === 86,
|
|
434
|
-
);
|
|
435
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
436
|
-
assert.equal(fs.statSync(worktrees).isFile(), true);
|
|
437
|
-
assert.equal(fs.existsSync(`${await gitPath(repo, 'HEAD')}.lock`), true);
|
|
438
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
439
|
-
|
|
440
|
-
recoverGitMetadataTransactions(repo);
|
|
441
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
442
|
-
assert.equal(fs.existsSync(`${await gitPath(repo, 'HEAD')}.lock`), false);
|
|
443
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
444
|
-
|
|
445
|
-
await updateBranchRefs(repo, [
|
|
446
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
447
|
-
], 'f'.repeat(64));
|
|
448
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
449
|
-
await releaseBranchReceipt(repo, 'f'.repeat(64));
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
test('namespace recovery resumes after restore crossed but receipt cleanup crashed', async () => {
|
|
453
|
-
const { repo, first } = await makeRepo('branch-delete-namespace-cleanup-recovery');
|
|
454
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
455
|
-
const transitionFile = path.resolve(
|
|
456
|
-
__dirname,
|
|
457
|
-
'../workspace/transition.js',
|
|
458
|
-
);
|
|
459
|
-
const source = `
|
|
460
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
461
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
462
|
-
updateBranchRefs(
|
|
463
|
-
${JSON.stringify(repo)},
|
|
464
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
465
|
-
${JSON.stringify('9'.repeat(64))},
|
|
466
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
467
|
-
);
|
|
468
|
-
`;
|
|
469
|
-
await assert.rejects(
|
|
470
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
471
|
-
(error) => error.code === 86,
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
const records = path.join(
|
|
475
|
-
process.env.AMALGM_DIR,
|
|
476
|
-
'state',
|
|
477
|
-
'workspace-git-worktree-guards',
|
|
478
|
-
);
|
|
479
|
-
const [recordName] = fs.readdirSync(records);
|
|
480
|
-
const transaction = JSON.parse(
|
|
481
|
-
fs.readFileSync(path.join(records, recordName), 'utf8'),
|
|
482
|
-
);
|
|
483
|
-
const worktrees = path.join(transaction.root, 'worktrees');
|
|
484
|
-
const prepared = path.join(
|
|
485
|
-
transaction.root,
|
|
486
|
-
'amalgm-metadata-apply',
|
|
487
|
-
`${transaction.id}.target`,
|
|
488
|
-
);
|
|
489
|
-
const swapping = path.join(
|
|
490
|
-
transaction.root,
|
|
491
|
-
'amalgm-metadata-apply',
|
|
492
|
-
`${transaction.id}.restore-gap`,
|
|
493
|
-
);
|
|
494
|
-
fs.renameSync(worktrees, swapping);
|
|
495
|
-
fs.renameSync(prepared, worktrees);
|
|
496
|
-
fs.renameSync(swapping, prepared);
|
|
497
|
-
fs.unlinkSync(prepared);
|
|
498
|
-
|
|
499
|
-
const replaced = `${worktrees}.replaced`;
|
|
500
|
-
fs.renameSync(worktrees, replaced);
|
|
501
|
-
fs.mkdirSync(worktrees);
|
|
502
|
-
assert.throws(
|
|
503
|
-
() => recoverGitMetadataTransactions(repo),
|
|
504
|
-
/worktree namespace guard receipt is incomplete/,
|
|
505
|
-
);
|
|
506
|
-
fs.rmdirSync(worktrees);
|
|
507
|
-
fs.renameSync(replaced, worktrees);
|
|
508
|
-
|
|
509
|
-
recoverGitMetadataTransactions(repo);
|
|
510
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
511
|
-
assert.equal(fs.existsSync(path.join(
|
|
512
|
-
transaction.root,
|
|
513
|
-
'amalgm-metadata-apply',
|
|
514
|
-
`${transaction.id}.claimed`,
|
|
515
|
-
)), false);
|
|
516
|
-
assert.equal(fs.existsSync(path.join(records, recordName)), false);
|
|
517
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
test('a renamed checkout recovers guards by physical repository identity', async () => {
|
|
521
|
-
const { repo, first } = await makeRepo('branch-delete-renamed-recovery');
|
|
522
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
523
|
-
const transitionFile = path.resolve(
|
|
524
|
-
__dirname,
|
|
525
|
-
'../workspace/transition.js',
|
|
526
|
-
);
|
|
527
|
-
const source = `
|
|
528
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
529
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
530
|
-
updateBranchRefs(
|
|
531
|
-
${JSON.stringify(repo)},
|
|
532
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
533
|
-
${JSON.stringify('8'.repeat(64))},
|
|
534
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
535
|
-
);
|
|
536
|
-
`;
|
|
537
|
-
await assert.rejects(
|
|
538
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
539
|
-
(error) => error.code === 86,
|
|
540
|
-
);
|
|
541
|
-
const moved = `${repo}-moved`;
|
|
542
|
-
fs.renameSync(repo, moved);
|
|
543
|
-
|
|
544
|
-
recoverGitMetadataTransactions(moved);
|
|
545
|
-
assert.equal(fs.statSync(await gitPath(moved, 'worktrees')).isDirectory(), true);
|
|
546
|
-
assert.equal(fs.existsSync(`${await gitPath(moved, 'HEAD')}.lock`), false);
|
|
547
|
-
assert.equal(await branch(moved, 'delete-me'), first);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
test('a linked worktree recovers a process death after namespace exchange', async () => {
|
|
551
|
-
const { repo, first } = await makeRepo('branch-delete-linked-namespace-recovery');
|
|
552
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
553
|
-
const linked = path.join(tempRoot, 'branch-delete-linked-namespace-recovery-peer');
|
|
554
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', linked, first);
|
|
555
|
-
const operation = 'b'.repeat(64);
|
|
556
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
557
|
-
const transitionFile = path.resolve(
|
|
558
|
-
__dirname,
|
|
559
|
-
'../workspace/transition.js',
|
|
560
|
-
);
|
|
561
|
-
const source = `
|
|
562
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
563
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
564
|
-
updateBranchRefs(
|
|
565
|
-
${JSON.stringify(linked)},
|
|
566
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
567
|
-
${JSON.stringify(operation)},
|
|
568
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
569
|
-
);
|
|
570
|
-
`;
|
|
571
|
-
await assert.rejects(
|
|
572
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
573
|
-
(error) => error.code === 86,
|
|
574
|
-
);
|
|
575
|
-
assert.equal(fs.statSync(worktrees).isFile(), true);
|
|
576
|
-
|
|
577
|
-
recoverGitMetadataTransactions(linked);
|
|
578
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
579
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'peer');
|
|
580
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
581
|
-
|
|
582
|
-
await updateBranchRefs(linked, [
|
|
583
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
584
|
-
], operation);
|
|
585
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
586
|
-
await releaseBranchReceipt(linked, operation);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
test('a sibling linked worktree recovers a namespace guard left by its peer', async () => {
|
|
590
|
-
const { repo, first } = await makeRepo('branch-delete-sibling-namespace-recovery');
|
|
591
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
592
|
-
const origin = path.join(tempRoot, 'branch-delete-sibling-namespace-recovery-origin');
|
|
593
|
-
const sibling = path.join(tempRoot, 'branch-delete-sibling-namespace-recovery-sibling');
|
|
594
|
-
await git(repo, 'worktree', 'add', '-b', 'origin', origin, first);
|
|
595
|
-
await git(repo, 'worktree', 'add', '-b', 'sibling', sibling, first);
|
|
596
|
-
const operation = '6'.repeat(64);
|
|
597
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
598
|
-
const transitionFile = path.resolve(__dirname, '../workspace/transition.js');
|
|
599
|
-
const source = `
|
|
600
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
601
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
602
|
-
updateBranchRefs(
|
|
603
|
-
${JSON.stringify(origin)},
|
|
604
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
605
|
-
${JSON.stringify(operation)},
|
|
606
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
607
|
-
);
|
|
608
|
-
`;
|
|
609
|
-
await assert.rejects(
|
|
610
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
611
|
-
(error) => error.code === 86,
|
|
612
|
-
);
|
|
613
|
-
assert.equal(fs.statSync(worktrees).isFile(), true);
|
|
614
|
-
|
|
615
|
-
recoverGitMetadataTransactions(sibling);
|
|
616
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
617
|
-
assert.equal(await git(sibling, 'symbolic-ref', '--short', 'HEAD'), 'sibling');
|
|
618
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
619
|
-
|
|
620
|
-
await updateBranchRefs(sibling, [
|
|
621
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
622
|
-
], operation);
|
|
623
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
624
|
-
await releaseBranchReceipt(sibling, operation);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
test('a linked worktree alias finds its namespace guard after process death', async () => {
|
|
628
|
-
const { repo, first } = await makeRepo('branch-delete-linked-alias-recovery');
|
|
629
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
630
|
-
const linked = path.join(tempRoot, 'branch-delete-linked-alias-recovery-peer');
|
|
631
|
-
const alias = path.join(tempRoot, 'branch-delete-linked-alias-recovery-alias');
|
|
632
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', linked, first);
|
|
633
|
-
fs.symlinkSync(linked, alias, 'dir');
|
|
634
|
-
const operation = '7'.repeat(64);
|
|
635
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
636
|
-
const transitionFile = path.resolve(
|
|
637
|
-
__dirname,
|
|
638
|
-
'../workspace/transition.js',
|
|
639
|
-
);
|
|
640
|
-
const source = `
|
|
641
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
642
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
643
|
-
updateBranchRefs(
|
|
644
|
-
${JSON.stringify(alias)},
|
|
645
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
646
|
-
${JSON.stringify(operation)},
|
|
647
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
648
|
-
);
|
|
649
|
-
`;
|
|
650
|
-
await assert.rejects(
|
|
651
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
652
|
-
(error) => error.code === 86,
|
|
653
|
-
);
|
|
654
|
-
assert.equal(fs.statSync(worktrees).isFile(), true);
|
|
655
|
-
|
|
656
|
-
// The linked checkout cannot ask Git for its common directory while the
|
|
657
|
-
// registration namespace is sealed. Its device/inode identity must find
|
|
658
|
-
// the record even though the dead process entered through a symlink alias.
|
|
659
|
-
recoverGitMetadataTransactions(linked);
|
|
660
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
661
|
-
assert.equal(await git(linked, 'symbolic-ref', '--short', 'HEAD'), 'peer');
|
|
662
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
663
|
-
|
|
664
|
-
await updateBranchRefs(linked, [
|
|
665
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
666
|
-
], operation);
|
|
667
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
668
|
-
await releaseBranchReceipt(linked, operation);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
test('the main checkout restores a namespace guard left by a linked checkout', async () => {
|
|
672
|
-
const { repo, first } = await makeRepo('branch-delete-cross-checkout-recovery');
|
|
673
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
674
|
-
const linked = path.join(tempRoot, 'branch-delete-cross-checkout-recovery-peer');
|
|
675
|
-
await git(repo, 'worktree', 'add', '-b', 'peer', linked, first);
|
|
676
|
-
const operation = 'c'.repeat(64);
|
|
677
|
-
const worktrees = await gitPath(repo, 'worktrees');
|
|
678
|
-
const transitionFile = path.resolve(
|
|
679
|
-
__dirname,
|
|
680
|
-
'../workspace/transition.js',
|
|
681
|
-
);
|
|
682
|
-
const source = `
|
|
683
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
684
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
685
|
-
updateBranchRefs(
|
|
686
|
-
${JSON.stringify(linked)},
|
|
687
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
688
|
-
${JSON.stringify(operation)},
|
|
689
|
-
{ afterWorktreeNamespaceGuard: () => process.exit(86) },
|
|
690
|
-
);
|
|
691
|
-
`;
|
|
692
|
-
await assert.rejects(
|
|
693
|
-
execFileAsync(process.execPath, ['-e', source]),
|
|
694
|
-
(error) => error.code === 86,
|
|
695
|
-
);
|
|
696
|
-
assert.equal(fs.statSync(worktrees).isFile(), true);
|
|
697
|
-
|
|
698
|
-
recoverGitMetadataTransactions(repo);
|
|
699
|
-
assert.equal(fs.statSync(worktrees).isDirectory(), true);
|
|
700
|
-
assert.equal(await branch(repo, 'delete-me'), first);
|
|
701
|
-
|
|
702
|
-
// The ordinary retry reaches the linked checkout, releases its own stale
|
|
703
|
-
// HEAD guard, and then performs the one ref transaction.
|
|
704
|
-
await updateBranchRefs(repo, [
|
|
705
|
-
{ branch: 'delete-me', expectedOid: first, oid: null },
|
|
706
|
-
], operation);
|
|
707
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
708
|
-
await releaseBranchReceipt(repo, operation);
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
test('concurrent attempts of one branch operation cannot release each other', async () => {
|
|
712
|
-
const { repo, first } = await makeRepo('branch-delete-same-operation-concurrency');
|
|
713
|
-
await git(repo, 'branch', 'delete-me', first);
|
|
714
|
-
const operation = '5'.repeat(64);
|
|
715
|
-
const ready = path.join(tempRoot, 'branch-delete-same-operation.ready');
|
|
716
|
-
const proceed = path.join(tempRoot, 'branch-delete-same-operation.proceed');
|
|
717
|
-
const transitionFile = path.resolve(__dirname, '../workspace/transition.js');
|
|
718
|
-
const source = `
|
|
719
|
-
process.env.AMALGM_DIR = ${JSON.stringify(process.env.AMALGM_DIR)};
|
|
720
|
-
const fs = require('fs');
|
|
721
|
-
const { updateBranchRefs } = require(${JSON.stringify(transitionFile)});
|
|
722
|
-
(async () => {
|
|
723
|
-
await updateBranchRefs(
|
|
724
|
-
${JSON.stringify(repo)},
|
|
725
|
-
[{ branch: 'delete-me', expectedOid: ${JSON.stringify(first)}, oid: null }],
|
|
726
|
-
${JSON.stringify(operation)},
|
|
727
|
-
{
|
|
728
|
-
afterHeadGuard: async () => {
|
|
729
|
-
fs.writeFileSync(${JSON.stringify(ready)}, 'ready');
|
|
730
|
-
while (!fs.existsSync(${JSON.stringify(proceed)})) {
|
|
731
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
732
|
-
}
|
|
733
|
-
},
|
|
734
|
-
},
|
|
735
|
-
);
|
|
736
|
-
})().catch((error) => {
|
|
737
|
-
console.error(error);
|
|
738
|
-
process.exit(1);
|
|
739
|
-
});
|
|
740
|
-
`;
|
|
741
|
-
const owner = delayedNode(source);
|
|
742
|
-
const deadline = Date.now() + 5_000;
|
|
743
|
-
while (!fs.existsSync(ready) && Date.now() < deadline) {
|
|
744
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
745
|
-
}
|
|
746
|
-
assert.equal(fs.existsSync(ready), true);
|
|
747
|
-
|
|
748
|
-
await assert.rejects(
|
|
749
|
-
updateBranchRefs(
|
|
750
|
-
repo,
|
|
751
|
-
[{ branch: 'delete-me', expectedOid: first, oid: null }],
|
|
752
|
-
operation,
|
|
753
|
-
),
|
|
754
|
-
/guard owner \d+ is still active|Git HEAD lock is already held/,
|
|
755
|
-
);
|
|
756
|
-
fs.writeFileSync(proceed, 'proceed');
|
|
757
|
-
await owner.done;
|
|
758
|
-
|
|
759
|
-
assert.equal(await branch(repo, 'delete-me'), null);
|
|
760
|
-
assert.equal(fs.existsSync(`${await gitPath(repo, 'HEAD')}.lock`), false);
|
|
761
|
-
assert.equal(fs.statSync(await gitPath(repo, 'worktrees')).isDirectory(), true);
|
|
762
|
-
await releaseBranchReceipt(repo, operation);
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
test('a branch transaction carries and releases its own crossing receipt', async () => {
|
|
766
|
-
const { repo, first, second } = await makeRepo('branch-receipt');
|
|
767
|
-
const operation = 'a'.repeat(64);
|
|
768
|
-
await git(repo, 'branch', 'feature', first);
|
|
769
|
-
|
|
770
|
-
assert.equal(await branchReceiptCrossed(repo, operation), false);
|
|
771
|
-
await updateBranchRefs(repo, [
|
|
772
|
-
{ branch: 'feature', expectedOid: first, oid: second },
|
|
773
|
-
], operation);
|
|
774
|
-
assert.equal(await branchReceiptCrossed(repo, operation), true);
|
|
775
|
-
|
|
776
|
-
await git(repo, 'update-ref', 'refs/heads/feature', first, second);
|
|
777
|
-
assert.equal(await branchReceiptCrossed(repo, operation), true);
|
|
778
|
-
await releaseBranchReceipt(repo, operation);
|
|
779
|
-
await releaseBranchReceipt(repo, operation);
|
|
780
|
-
assert.equal(await branchReceiptCrossed(repo, operation), false);
|
|
781
|
-
});
|
|
782
|
-
|
|
783
|
-
test('one operation records independent receipts for multiple branch batches', async () => {
|
|
784
|
-
const { repo, first, second, third } = await makeRepo('multi-branch-receipts');
|
|
785
|
-
const operation = '9'.repeat(64);
|
|
786
|
-
await updateBranchRefs(repo, [
|
|
787
|
-
{ branch: 'first-batch', expectedOid: null, oid: second },
|
|
788
|
-
], operation);
|
|
789
|
-
await updateBranchRefs(repo, [
|
|
790
|
-
{ branch: 'second-batch', expectedOid: null, oid: third },
|
|
791
|
-
], operation);
|
|
792
|
-
assert.equal(await branchReceiptCrossed(repo, operation, 'first-batch'), true);
|
|
793
|
-
assert.equal(await branchReceiptCrossed(repo, operation, 'second-batch'), true);
|
|
794
|
-
assert.equal(await branch(repo, 'first-batch'), second);
|
|
795
|
-
assert.equal(await branch(repo, 'second-batch'), third);
|
|
796
|
-
assert.notEqual(first, second);
|
|
797
|
-
await releaseBranchReceipt(repo, operation);
|
|
798
|
-
assert.equal(await branchReceiptCrossed(repo, operation), false);
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
test('a Git index crash before lock rename has no crossing receipt', async () => {
|
|
802
|
-
const { repo } = await makeRepo('index-pre-cross-receipt');
|
|
803
|
-
const operation = 'b'.repeat(64);
|
|
804
|
-
const index = await gitPath(repo, 'index');
|
|
805
|
-
const expected = fs.readFileSync(index);
|
|
806
|
-
const target = Buffer.from('prepared replacement index bytes\n');
|
|
807
|
-
|
|
808
|
-
assert.throws(
|
|
809
|
-
() => applyGitMetadataTransaction(
|
|
810
|
-
repo,
|
|
811
|
-
'index',
|
|
812
|
-
expected,
|
|
813
|
-
target,
|
|
814
|
-
operation,
|
|
815
|
-
{ crashBeforeInstall: true },
|
|
816
|
-
),
|
|
817
|
-
/native workspace file transaction failed/,
|
|
818
|
-
);
|
|
819
|
-
assert.equal(fs.readFileSync(index).equals(expected), true);
|
|
820
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'index'), false);
|
|
821
|
-
assert.equal(fs.existsSync(`${index}.lock`), false);
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
test('a crossed index receipt survives a revert and a later Git lock', async () => {
|
|
825
|
-
const { repo } = await makeRepo('index-post-cross-receipt');
|
|
826
|
-
const operation = 'c'.repeat(64);
|
|
827
|
-
const index = await gitPath(repo, 'index');
|
|
828
|
-
const expected = fs.readFileSync(index);
|
|
829
|
-
const target = Buffer.from('official replacement index bytes\n');
|
|
830
|
-
|
|
831
|
-
assert.throws(
|
|
832
|
-
() => applyGitMetadataTransaction(
|
|
833
|
-
repo,
|
|
834
|
-
'index',
|
|
835
|
-
expected,
|
|
836
|
-
target,
|
|
837
|
-
operation,
|
|
838
|
-
{ crashAfterInstall: true },
|
|
839
|
-
),
|
|
840
|
-
/native workspace file transaction failed/,
|
|
841
|
-
);
|
|
842
|
-
assert.equal(fs.readFileSync(index).equals(target), true);
|
|
843
|
-
|
|
844
|
-
replaceThroughLock(index, expected);
|
|
845
|
-
fs.writeFileSync(`${index}.lock`, 'later user-owned lock\n');
|
|
846
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'index'), true);
|
|
847
|
-
releaseGitMetadataReceipts(repo, operation, 'index');
|
|
848
|
-
releaseGitMetadataReceipts(repo, operation, 'index');
|
|
849
|
-
assert.equal(fs.readFileSync(`${index}.lock`, 'utf8'), 'later user-owned lock\n');
|
|
850
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'index'), false);
|
|
851
|
-
fs.unlinkSync(`${index}.lock`);
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
test('a crossed HEAD receipt survives a later switch back', async () => {
|
|
855
|
-
const { repo, first } = await makeRepo('head-post-cross-receipt');
|
|
856
|
-
const operation = 'd'.repeat(64);
|
|
857
|
-
const head = await gitPath(repo, 'HEAD');
|
|
858
|
-
const expected = fs.readFileSync(head);
|
|
859
|
-
const target = Buffer.from(`${first}\n`);
|
|
860
|
-
|
|
861
|
-
applyGitMetadataTransaction(repo, 'HEAD', expected, target, operation);
|
|
862
|
-
assert.equal(fs.readFileSync(head).equals(target), true);
|
|
863
|
-
replaceThroughLock(head, expected);
|
|
864
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'HEAD'), true);
|
|
865
|
-
releaseGitMetadataReceipts(repo, operation, 'HEAD');
|
|
866
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'HEAD'), false);
|
|
867
|
-
assert.equal(fs.readFileSync(head).equals(expected), true);
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
test('Git metadata receipt release resumes after native cleanup but before JSON removal', async () => {
|
|
871
|
-
for (const [component, operation] of [
|
|
872
|
-
['index', '1'.repeat(64)],
|
|
873
|
-
['HEAD', '2'.repeat(64)],
|
|
874
|
-
]) {
|
|
875
|
-
const { repo, first } = await makeRepo(`release-gap-${component}`);
|
|
876
|
-
const destination = await gitPath(repo, component);
|
|
877
|
-
const expected = fs.readFileSync(destination);
|
|
878
|
-
const target = component === 'HEAD'
|
|
879
|
-
? Buffer.from(`${first}\n`)
|
|
880
|
-
: Buffer.from('release-gap-index\n');
|
|
881
|
-
applyGitMetadataTransaction(repo, component, expected, target, operation);
|
|
882
|
-
const receiptDir = path.join(path.dirname(destination), 'amalgm-metadata-apply');
|
|
883
|
-
for (const name of fs.readdirSync(receiptDir)) {
|
|
884
|
-
if (name.endsWith('.target') || name.endsWith('.claimed')) {
|
|
885
|
-
fs.rmSync(path.join(receiptDir, name));
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
releaseGitMetadataReceipts(repo, operation, component);
|
|
889
|
-
assert.equal(
|
|
890
|
-
fs.readdirSync(receiptDir).some((name) => name.endsWith('.json')),
|
|
891
|
-
false,
|
|
892
|
-
);
|
|
893
|
-
}
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
test('missing-index initialization cannot erase a staged index won by a racing user', async () => {
|
|
897
|
-
const repo = path.join(tempRoot, 'empty-index-race');
|
|
898
|
-
fs.mkdirSync(repo);
|
|
899
|
-
await git(repo, 'init', '-b', 'main');
|
|
900
|
-
fs.writeFileSync(path.join(repo, 'local.txt'), 'local bytes\n');
|
|
901
|
-
const operation = '3'.repeat(64);
|
|
902
|
-
await assert.rejects(
|
|
903
|
-
ensureEmptyIndex(repo, operation, {
|
|
904
|
-
afterAbsent: async () => {
|
|
905
|
-
await git(repo, 'add', 'local.txt');
|
|
906
|
-
},
|
|
907
|
-
}),
|
|
908
|
-
(error) => error instanceof TransitionError,
|
|
909
|
-
);
|
|
910
|
-
assert.match(await git(repo, 'diff', '--cached', '--name-only'), /local\.txt/);
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
test('a large index target is prepared as a file instead of embedded in JSON', async () => {
|
|
914
|
-
const { repo } = await makeRepo('large-index-receipt');
|
|
915
|
-
const operation = 'f'.repeat(64);
|
|
916
|
-
const index = await gitPath(repo, 'index');
|
|
917
|
-
const privateGitDir = path.dirname(index);
|
|
918
|
-
const expected = fs.readFileSync(index);
|
|
919
|
-
const target = Buffer.alloc(17 * 1024 * 1024, 0x61);
|
|
920
|
-
|
|
921
|
-
applyGitMetadataTransaction(repo, 'index', expected, target, operation);
|
|
922
|
-
const receiptDir = path.join(privateGitDir, 'amalgm-metadata-apply');
|
|
923
|
-
const record = fs.readdirSync(receiptDir).find((name) => name.endsWith('.json'));
|
|
924
|
-
assert.ok(record);
|
|
925
|
-
assert.ok(fs.statSync(path.join(receiptDir, record)).size < 2048);
|
|
926
|
-
assert.equal(fs.readFileSync(index).equals(target), true);
|
|
927
|
-
assert.equal(gitMetadataReceiptCrossed(repo, operation, 'index'), true);
|
|
928
|
-
releaseGitMetadataReceipts(repo, operation, 'index');
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
test('Git metadata receipts live in a linked worktree private Git directory', async () => {
|
|
932
|
-
const { repo, first } = await makeRepo('linked-metadata-main');
|
|
933
|
-
const linked = path.join(tempRoot, 'linked-metadata-worktree');
|
|
934
|
-
await git(repo, 'worktree', 'add', '-b', 'linked-metadata', linked, first);
|
|
935
|
-
const operation = 'e'.repeat(64);
|
|
936
|
-
const head = await gitPath(linked, 'HEAD');
|
|
937
|
-
const privateGitDir = path.dirname(head);
|
|
938
|
-
const commonValue = await git(linked, 'rev-parse', '--git-common-dir');
|
|
939
|
-
const commonGitDir = path.isAbsolute(commonValue)
|
|
940
|
-
? fs.realpathSync(commonValue)
|
|
941
|
-
: fs.realpathSync(path.resolve(linked, commonValue));
|
|
942
|
-
const expected = fs.readFileSync(head);
|
|
943
|
-
const target = Buffer.from(`${first}\n`);
|
|
944
|
-
|
|
945
|
-
applyGitMetadataTransaction(linked, 'HEAD', expected, target, operation);
|
|
946
|
-
assert.equal(gitMetadataReceiptCrossed(linked, operation, 'HEAD'), true);
|
|
947
|
-
assert.equal(
|
|
948
|
-
fs.existsSync(path.join(privateGitDir, 'amalgm-metadata-apply')),
|
|
949
|
-
true,
|
|
950
|
-
);
|
|
951
|
-
if (privateGitDir !== commonGitDir) {
|
|
952
|
-
assert.equal(
|
|
953
|
-
fs.existsSync(path.join(commonGitDir, 'amalgm-metadata-apply')),
|
|
954
|
-
false,
|
|
955
|
-
);
|
|
956
|
-
}
|
|
957
|
-
releaseGitMetadataReceipts(linked, operation, 'HEAD');
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
test('one stale expected value aborts every branch movement', async () => {
|
|
961
|
-
const { repo, first, second, third } = await makeRepo('stale');
|
|
962
|
-
await git(repo, 'branch', 'unchanged-create');
|
|
963
|
-
await git(repo, 'branch', 'stale-update', second);
|
|
964
|
-
await git(repo, 'branch', 'unchanged-delete', first);
|
|
965
|
-
|
|
966
|
-
await assert.rejects(
|
|
967
|
-
updateBranchRefs(repo, [
|
|
968
|
-
{ branch: 'new-branch', expectedOid: null, oid: third },
|
|
969
|
-
{ branch: 'stale-update', expectedOid: first, oid: third },
|
|
970
|
-
{ branch: 'unchanged-delete', expectedOid: first, oid: null },
|
|
971
|
-
]),
|
|
972
|
-
(error) => error instanceof TransitionError
|
|
973
|
-
&& error.action === 'update-branch-refs',
|
|
974
|
-
);
|
|
975
|
-
|
|
976
|
-
assert.equal(await branch(repo, 'new-branch'), null);
|
|
977
|
-
assert.equal(await branch(repo, 'stale-update'), second);
|
|
978
|
-
assert.equal(await branch(repo, 'unchanged-delete'), first);
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
test('a card metadata move leaves pending index and working bytes untouched', async () => {
|
|
982
|
-
const { repo, first, third } = await makeRepo('head-only');
|
|
983
|
-
const operation = '7'.repeat(64);
|
|
984
|
-
await git(repo, 'reset', '--hard', first);
|
|
985
|
-
fs.writeFileSync(path.join(repo, 'staged.txt'), 'staged local bytes\n');
|
|
986
|
-
await git(repo, 'add', 'staged.txt');
|
|
987
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'unstaged local bytes\n');
|
|
988
|
-
|
|
989
|
-
await moveHeadToCard(repo, {
|
|
990
|
-
expectedBranch: 'main',
|
|
991
|
-
expectedOid: first,
|
|
992
|
-
expectedTargetOid: first,
|
|
993
|
-
targetBranch: 'main',
|
|
994
|
-
targetOid: third,
|
|
995
|
-
operation,
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
assert.equal(await git(repo, 'rev-parse', 'HEAD'), third);
|
|
999
|
-
assert.equal(fs.readFileSync(path.join(repo, 'file.txt'), 'utf8'), 'unstaged local bytes\n');
|
|
1000
|
-
assert.equal(fs.readFileSync(path.join(repo, 'staged.txt'), 'utf8'), 'staged local bytes\n');
|
|
1001
|
-
assert.match(await git(repo, 'diff', '--cached', '--name-only'), /staged\.txt/);
|
|
1002
|
-
|
|
1003
|
-
await moveHeadToCard(repo, {
|
|
1004
|
-
expectedBranch: 'main',
|
|
1005
|
-
expectedOid: first,
|
|
1006
|
-
expectedTargetOid: first,
|
|
1007
|
-
targetBranch: 'main',
|
|
1008
|
-
targetOid: third,
|
|
1009
|
-
operation,
|
|
1010
|
-
});
|
|
1011
|
-
assert.equal(await git(repo, 'rev-parse', 'HEAD'), third);
|
|
1012
|
-
await releaseBranchReceipt(repo, operation);
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
test('an interrupted file replacement leaves the old file whole and replay lands the new file whole', async () => {
|
|
1016
|
-
const { repo } = await makeRepo('atomic-file');
|
|
1017
|
-
const file = path.join(repo, 'file.txt');
|
|
1018
|
-
const write = {
|
|
1019
|
-
path: 'file.txt',
|
|
1020
|
-
content: Buffer.from('official replacement\n'),
|
|
1021
|
-
mode: '100644',
|
|
1022
|
-
expected: {
|
|
1023
|
-
kind: 'file',
|
|
1024
|
-
content: Buffer.from('three\n'),
|
|
1025
|
-
exec: false,
|
|
1026
|
-
},
|
|
1027
|
-
testCrashAfterExtraction: true,
|
|
1028
|
-
};
|
|
1029
|
-
assert.throws(
|
|
1030
|
-
() => applyFiles(repo, [write], 'interrupted'),
|
|
1031
|
-
/native workspace file transaction failed/,
|
|
1032
|
-
);
|
|
1033
|
-
|
|
1034
|
-
recoverFileTransactions(repo);
|
|
1035
|
-
assert.deepEqual([...crossedFileReceiptPaths(repo, 'interrupted', ['file.txt'])], []);
|
|
1036
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'three\n');
|
|
1037
|
-
assert.equal(
|
|
1038
|
-
fs.readdirSync(repo).some((name) => name.startsWith('.amalgm-apply-')),
|
|
1039
|
-
false,
|
|
1040
|
-
);
|
|
1041
|
-
|
|
1042
|
-
applyFiles(repo, [{ ...write, testCrashAfterExtraction: false }], 'interrupted');
|
|
1043
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'official replacement\n');
|
|
1044
|
-
assert.deepEqual([...crossedFileReceiptPaths(repo, 'interrupted', ['file.txt'])], ['file.txt']);
|
|
1045
|
-
releaseFileReceipts(repo, 'interrupted');
|
|
1046
|
-
});
|
|
1047
|
-
|
|
1048
|
-
test('an official add remains crossed after a later local delete', async () => {
|
|
1049
|
-
const { repo } = await makeRepo('add-delete-receipt');
|
|
1050
|
-
const operation = 'add-delete';
|
|
1051
|
-
const write = {
|
|
1052
|
-
path: 'added.txt',
|
|
1053
|
-
content: Buffer.from('official add\n'),
|
|
1054
|
-
mode: '100644',
|
|
1055
|
-
expected: null,
|
|
1056
|
-
};
|
|
1057
|
-
applyFiles(repo, [write], operation);
|
|
1058
|
-
fs.unlinkSync(path.join(repo, 'added.txt'));
|
|
1059
|
-
|
|
1060
|
-
assert.deepEqual(
|
|
1061
|
-
[...crossedFileReceiptPaths(repo, operation, ['added.txt'])],
|
|
1062
|
-
['added.txt'],
|
|
1063
|
-
);
|
|
1064
|
-
applyFiles(repo, [write], operation);
|
|
1065
|
-
assert.equal(fs.existsSync(path.join(repo, 'added.txt')), false);
|
|
1066
|
-
releaseFileReceipts(repo, operation);
|
|
1067
|
-
releaseFileReceipts(repo, operation);
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
test('retained receipts are invisible to status, untracked listing, and capture', async () => {
|
|
1071
|
-
const { repo } = await makeRepo('receipt-is-not-content');
|
|
1072
|
-
const operation = 'receipt-is-not-content';
|
|
1073
|
-
applyFiles(repo, [{
|
|
1074
|
-
path: 'added.txt',
|
|
1075
|
-
content: Buffer.from('official add\n'),
|
|
1076
|
-
mode: '100644',
|
|
1077
|
-
expected: null,
|
|
1078
|
-
}], operation);
|
|
1079
|
-
|
|
1080
|
-
const status = await git(repo, 'status', '--porcelain=v1', '--untracked-files=all');
|
|
1081
|
-
const untracked = await git(repo, 'ls-files', '--others', '--exclude-standard');
|
|
1082
|
-
const captured = await captureWorktreeDiff(repo);
|
|
1083
|
-
const checkpoint = await captureCheckpoint(repo);
|
|
1084
|
-
assert.doesNotMatch(status, /\.amalgm-apply-/);
|
|
1085
|
-
assert.doesNotMatch(untracked, /\.amalgm-apply-/);
|
|
1086
|
-
assert.doesNotMatch(captured.diff, /\.amalgm-apply-/);
|
|
1087
|
-
assert.doesNotMatch(checkpoint.worktreeDiff, /\.amalgm-apply-/);
|
|
1088
|
-
|
|
1089
|
-
const lookalike = path.join(repo, '.amalgm-apply-not-a-receipt');
|
|
1090
|
-
fs.mkdirSync(lookalike);
|
|
1091
|
-
fs.writeFileSync(path.join(lookalike, 'user.txt'), 'user content\n');
|
|
1092
|
-
assert.match(
|
|
1093
|
-
await git(repo, 'ls-files', '--others', '--exclude-standard'),
|
|
1094
|
-
/\.amalgm-apply-not-a-receipt\/user\.txt/,
|
|
1095
|
-
);
|
|
1096
|
-
releaseFileReceipts(repo, operation);
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
test('linked worktrees exclude receipts through Git without clobbering a racing user edit', async () => {
|
|
1100
|
-
const { repo, first } = await makeRepo('linked-receipt-main');
|
|
1101
|
-
const linked = path.join(tempRoot, 'linked-receipt-worktree');
|
|
1102
|
-
await git(repo, 'worktree', 'add', '-b', 'linked-receipt', linked, first);
|
|
1103
|
-
const exclude = await gitPath(linked, 'info/exclude');
|
|
1104
|
-
fs.writeFileSync(exclude, 'user-before\n');
|
|
1105
|
-
|
|
1106
|
-
const readFileSync = fs.readFileSync;
|
|
1107
|
-
let raced = false;
|
|
1108
|
-
fs.readFileSync = (file, ...args) => {
|
|
1109
|
-
const value = readFileSync(file, ...args);
|
|
1110
|
-
if (!raced && path.resolve(String(file)) === path.resolve(exclude)) {
|
|
1111
|
-
raced = true;
|
|
1112
|
-
fs.appendFileSync(exclude, 'user-racing\n');
|
|
1113
|
-
}
|
|
1114
|
-
return value;
|
|
1115
|
-
};
|
|
1116
|
-
try {
|
|
1117
|
-
applyFiles(linked, [{
|
|
1118
|
-
path: 'file.txt',
|
|
1119
|
-
content: Buffer.from('official replacement\n'),
|
|
1120
|
-
mode: '100644',
|
|
1121
|
-
expected: {
|
|
1122
|
-
kind: 'file',
|
|
1123
|
-
content: Buffer.from('one\n'),
|
|
1124
|
-
exec: false,
|
|
1125
|
-
},
|
|
1126
|
-
}], 'linked-receipt');
|
|
1127
|
-
} finally {
|
|
1128
|
-
fs.readFileSync = readFileSync;
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
const excludeContent = fs.readFileSync(exclude, 'utf8');
|
|
1132
|
-
assert.match(excludeContent, /^user-before\nuser-racing\n/m);
|
|
1133
|
-
assert.match(excludeContent, /\.amalgm-apply-\[0-9a-f\]/);
|
|
1134
|
-
assert.equal(raced, true);
|
|
1135
|
-
assert.doesNotMatch(
|
|
1136
|
-
await git(linked, 'ls-files', '--others', '--exclude-standard'),
|
|
1137
|
-
/\.amalgm-apply-/,
|
|
1138
|
-
);
|
|
1139
|
-
assert.doesNotMatch(
|
|
1140
|
-
(await captureCheckpoint(linked)).worktreeDiff,
|
|
1141
|
-
/\.amalgm-apply-/,
|
|
1142
|
-
);
|
|
1143
|
-
releaseFileReceipts(linked, 'linked-receipt');
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
test('an official delete remains crossed after a later local recreate', async () => {
|
|
1147
|
-
const { repo } = await makeRepo('delete-recreate-receipt');
|
|
1148
|
-
const operation = 'delete-recreate';
|
|
1149
|
-
const write = {
|
|
1150
|
-
path: 'file.txt',
|
|
1151
|
-
content: null,
|
|
1152
|
-
expected: {
|
|
1153
|
-
kind: 'file',
|
|
1154
|
-
content: Buffer.from('three\n'),
|
|
1155
|
-
exec: false,
|
|
1156
|
-
},
|
|
1157
|
-
};
|
|
1158
|
-
applyFiles(repo, [write], operation);
|
|
1159
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'three\n');
|
|
1160
|
-
|
|
1161
|
-
assert.deepEqual(
|
|
1162
|
-
[...crossedFileReceiptPaths(repo, operation, ['file.txt'])],
|
|
1163
|
-
['file.txt'],
|
|
1164
|
-
);
|
|
1165
|
-
applyFiles(repo, [write], operation);
|
|
1166
|
-
assert.equal(fs.readFileSync(path.join(repo, 'file.txt'), 'utf8'), 'three\n');
|
|
1167
|
-
releaseFileReceipts(repo, operation);
|
|
1168
|
-
});
|
|
1169
|
-
|
|
1170
|
-
test('an official replacement remains crossed after a later return to its source bytes', async () => {
|
|
1171
|
-
const { repo } = await makeRepo('replace-source-receipt');
|
|
1172
|
-
const operation = 'replace-source';
|
|
1173
|
-
const write = {
|
|
1174
|
-
path: 'file.txt',
|
|
1175
|
-
content: Buffer.from('official replacement\n'),
|
|
1176
|
-
mode: '100644',
|
|
1177
|
-
expected: {
|
|
1178
|
-
kind: 'file',
|
|
1179
|
-
content: Buffer.from('three\n'),
|
|
1180
|
-
exec: false,
|
|
1181
|
-
},
|
|
1182
|
-
};
|
|
1183
|
-
applyFiles(repo, [write], operation);
|
|
1184
|
-
fs.writeFileSync(path.join(repo, 'file.txt'), 'three\n');
|
|
1185
|
-
|
|
1186
|
-
assert.deepEqual(
|
|
1187
|
-
[...crossedFileReceiptPaths(repo, operation, ['file.txt'])],
|
|
1188
|
-
['file.txt'],
|
|
1189
|
-
);
|
|
1190
|
-
applyFiles(repo, [write], operation);
|
|
1191
|
-
assert.equal(fs.readFileSync(path.join(repo, 'file.txt'), 'utf8'), 'three\n');
|
|
1192
|
-
releaseFileReceipts(repo, operation);
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
test('an official symlink add remains crossed after a later local delete', async () => {
|
|
1196
|
-
const { repo } = await makeRepo('symlink-add-delete-receipt');
|
|
1197
|
-
const operation = 'symlink-add-delete';
|
|
1198
|
-
const write = {
|
|
1199
|
-
path: 'link.txt',
|
|
1200
|
-
content: Buffer.from('missing-target'),
|
|
1201
|
-
mode: '120000',
|
|
1202
|
-
expected: null,
|
|
1203
|
-
};
|
|
1204
|
-
applyFiles(repo, [write], operation);
|
|
1205
|
-
fs.unlinkSync(path.join(repo, 'link.txt'));
|
|
1206
|
-
|
|
1207
|
-
assert.deepEqual(
|
|
1208
|
-
[...crossedFileReceiptPaths(repo, operation, ['link.txt'])],
|
|
1209
|
-
['link.txt'],
|
|
1210
|
-
);
|
|
1211
|
-
applyFiles(repo, [write], operation);
|
|
1212
|
-
assert.equal(fs.existsSync(path.join(repo, 'link.txt')), false);
|
|
1213
|
-
releaseFileReceipts(repo, operation);
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
test('a crash before crossing and an unrelated sibling write produce no receipt', async () => {
|
|
1217
|
-
const { repo } = await makeRepo('pre-cross-sibling');
|
|
1218
|
-
const operation = 'pre-cross-sibling';
|
|
1219
|
-
assert.throws(
|
|
1220
|
-
() => applyFiles(repo, [{
|
|
1221
|
-
path: 'added.txt',
|
|
1222
|
-
content: Buffer.from('official add\n'),
|
|
1223
|
-
mode: '100644',
|
|
1224
|
-
expected: null,
|
|
1225
|
-
testCrashBeforeInstall: true,
|
|
1226
|
-
}], operation),
|
|
1227
|
-
/native workspace file transaction failed/,
|
|
1228
|
-
);
|
|
1229
|
-
fs.writeFileSync(path.join(repo, 'sibling.txt'), 'unrelated local write\n');
|
|
1230
|
-
|
|
1231
|
-
recoverFileTransactions(repo);
|
|
1232
|
-
assert.deepEqual(
|
|
1233
|
-
[...crossedFileReceiptPaths(repo, operation, ['added.txt'])],
|
|
1234
|
-
[],
|
|
1235
|
-
);
|
|
1236
|
-
assert.equal(fs.existsSync(path.join(repo, 'added.txt')), false);
|
|
1237
|
-
assert.equal(
|
|
1238
|
-
fs.readFileSync(path.join(repo, 'sibling.txt'), 'utf8'),
|
|
1239
|
-
'unrelated local write\n',
|
|
1240
|
-
);
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
test('a pre-opened writer keeps its inode visible and its later save survives', async () => {
|
|
1244
|
-
const { repo } = await makeRepo('pre-opened-writer');
|
|
1245
|
-
const file = path.join(fs.realpathSync(repo), 'file.txt');
|
|
1246
|
-
const descriptor = fs.openSync(file, 'r+');
|
|
1247
|
-
try {
|
|
1248
|
-
assert.throws(
|
|
1249
|
-
() => applyFiles(repo, [{
|
|
1250
|
-
path: 'file.txt',
|
|
1251
|
-
content: Buffer.from('official replacement\n'),
|
|
1252
|
-
mode: '100644',
|
|
1253
|
-
expected: {
|
|
1254
|
-
kind: 'file',
|
|
1255
|
-
content: Buffer.from('three\n'),
|
|
1256
|
-
exec: false,
|
|
1257
|
-
},
|
|
1258
|
-
}], 'pre-opened-writer'),
|
|
1259
|
-
/local file changed before ordered apply/,
|
|
1260
|
-
);
|
|
1261
|
-
fs.ftruncateSync(descriptor, 0);
|
|
1262
|
-
fs.writeSync(descriptor, 'late local save\n', 0, 'utf8');
|
|
1263
|
-
fs.fsyncSync(descriptor);
|
|
1264
|
-
} finally {
|
|
1265
|
-
fs.closeSync(descriptor);
|
|
1266
|
-
}
|
|
1267
|
-
recoverFileTransactions(repo);
|
|
1268
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'late local save\n');
|
|
1269
|
-
});
|
|
1270
|
-
|
|
1271
|
-
test('a save before extraction is restored as local state instead of overwritten', async () => {
|
|
1272
|
-
const { repo } = await makeRepo('source-extraction-race');
|
|
1273
|
-
const file = path.join(fs.realpathSync(repo), 'file.txt');
|
|
1274
|
-
const writer = delayedNode(`
|
|
1275
|
-
const fs = require('fs');
|
|
1276
|
-
setTimeout(() => fs.writeFileSync(${JSON.stringify(file)}, 'newer local save\\n'), 60);
|
|
1277
|
-
`);
|
|
1278
|
-
assert.throws(
|
|
1279
|
-
() => applyFiles(repo, [{
|
|
1280
|
-
path: 'file.txt',
|
|
1281
|
-
content: Buffer.from('official replacement\n'),
|
|
1282
|
-
mode: '100644',
|
|
1283
|
-
expected: {
|
|
1284
|
-
kind: 'file',
|
|
1285
|
-
content: Buffer.from('three\n'),
|
|
1286
|
-
exec: false,
|
|
1287
|
-
},
|
|
1288
|
-
testPauseAfterParentOpenMillis: 250,
|
|
1289
|
-
}], 'source-race'),
|
|
1290
|
-
/local file changed before ordered apply/,
|
|
1291
|
-
);
|
|
1292
|
-
await writer.done;
|
|
1293
|
-
recoverFileTransactions(repo);
|
|
1294
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'newer local save\n');
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
test('a save after extraction wins the exclusive target installation race', async () => {
|
|
1298
|
-
const { repo } = await makeRepo('exclusive-install-race');
|
|
1299
|
-
const file = path.join(fs.realpathSync(repo), 'file.txt');
|
|
1300
|
-
const writer = delayedNode(`
|
|
1301
|
-
const fs = require('fs');
|
|
1302
|
-
setTimeout(() => fs.writeFileSync(${JSON.stringify(file)}, 'newer local save\\n'), 100);
|
|
1303
|
-
`);
|
|
1304
|
-
assert.throws(
|
|
1305
|
-
() => applyFiles(repo, [{
|
|
1306
|
-
path: 'file.txt',
|
|
1307
|
-
content: Buffer.from('official replacement\n'),
|
|
1308
|
-
mode: '100644',
|
|
1309
|
-
expected: {
|
|
1310
|
-
kind: 'file',
|
|
1311
|
-
content: Buffer.from('three\n'),
|
|
1312
|
-
exec: false,
|
|
1313
|
-
},
|
|
1314
|
-
testPauseBeforeInstallMillis: 300,
|
|
1315
|
-
}], 'install-race'),
|
|
1316
|
-
/local writer ownership is unresolved/,
|
|
1317
|
-
);
|
|
1318
|
-
await writer.done;
|
|
1319
|
-
recoverFileTransactions(repo);
|
|
1320
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'newer local save\n');
|
|
1321
|
-
});
|
|
1322
|
-
|
|
1323
|
-
test('a parent swapped to a symlink cannot redirect an ordered write outside the repo', async () => {
|
|
1324
|
-
const { repo } = await makeRepo('parent-swap-race');
|
|
1325
|
-
const nested = path.join(repo, 'nested');
|
|
1326
|
-
const held = path.join(repo, 'nested-local');
|
|
1327
|
-
const external = path.join(tempRoot, 'parent-swap-external');
|
|
1328
|
-
fs.mkdirSync(nested);
|
|
1329
|
-
fs.mkdirSync(external);
|
|
1330
|
-
fs.writeFileSync(path.join(nested, 'file.txt'), 'local floor\n');
|
|
1331
|
-
fs.writeFileSync(path.join(external, 'file.txt'), 'external bytes\n');
|
|
1332
|
-
const writer = delayedNode(`
|
|
1333
|
-
const fs = require('fs');
|
|
1334
|
-
setTimeout(() => {
|
|
1335
|
-
fs.renameSync(${JSON.stringify(nested)}, ${JSON.stringify(held)});
|
|
1336
|
-
fs.symlinkSync(${JSON.stringify(external)}, ${JSON.stringify(nested)});
|
|
1337
|
-
}, 80);
|
|
1338
|
-
`);
|
|
1339
|
-
|
|
1340
|
-
assert.throws(
|
|
1341
|
-
() => applyFiles(repo, [{
|
|
1342
|
-
path: 'nested/file.txt',
|
|
1343
|
-
content: Buffer.from('official replacement\n'),
|
|
1344
|
-
mode: '100644',
|
|
1345
|
-
expected: {
|
|
1346
|
-
kind: 'file',
|
|
1347
|
-
content: Buffer.from('local floor\n'),
|
|
1348
|
-
exec: false,
|
|
1349
|
-
},
|
|
1350
|
-
testPauseAfterParentOpenMillis: 300,
|
|
1351
|
-
}], 'parent-swap'),
|
|
1352
|
-
(error) => (
|
|
1353
|
-
error instanceof TransitionError
|
|
1354
|
-
&& error.action === 'apply-files'
|
|
1355
|
-
),
|
|
1356
|
-
);
|
|
1357
|
-
await writer.done;
|
|
1358
|
-
assert.equal(fs.readFileSync(path.join(external, 'file.txt'), 'utf8'), 'external bytes\n');
|
|
1359
|
-
assert.equal(fs.readFileSync(path.join(held, 'file.txt'), 'utf8'), 'local floor\n');
|
|
1360
|
-
});
|
|
1361
|
-
|
|
1362
|
-
test('exclusive file transactions install and replace dangling symlinks', async () => {
|
|
1363
|
-
const { repo } = await makeRepo('symlink-transaction');
|
|
1364
|
-
const file = path.join(fs.realpathSync(repo), 'file.txt');
|
|
1365
|
-
applyFiles(repo, [{
|
|
1366
|
-
path: 'file.txt',
|
|
1367
|
-
content: Buffer.from('missing-first'),
|
|
1368
|
-
mode: '120000',
|
|
1369
|
-
expected: {
|
|
1370
|
-
kind: 'file',
|
|
1371
|
-
content: Buffer.from('three\n'),
|
|
1372
|
-
exec: false,
|
|
1373
|
-
},
|
|
1374
|
-
}], 'symlink-first');
|
|
1375
|
-
assert.equal(fs.lstatSync(file).isSymbolicLink(), true);
|
|
1376
|
-
assert.equal(fs.readlinkSync(file), 'missing-first');
|
|
1377
|
-
|
|
1378
|
-
applyFiles(repo, [{
|
|
1379
|
-
path: 'file.txt',
|
|
1380
|
-
content: Buffer.from('missing-second'),
|
|
1381
|
-
mode: '120000',
|
|
1382
|
-
expected: {
|
|
1383
|
-
kind: 'symlink',
|
|
1384
|
-
content: Buffer.from('missing-first'),
|
|
1385
|
-
exec: false,
|
|
1386
|
-
},
|
|
1387
|
-
}], 'symlink-second');
|
|
1388
|
-
assert.equal(fs.lstatSync(file).isSymbolicLink(), true);
|
|
1389
|
-
assert.equal(fs.readlinkSync(file), 'missing-second');
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
test('a separate Git directory is never used for workspace data movement', async () => {
|
|
1393
|
-
const repo = path.join(tempRoot, 'separate-git-worktree');
|
|
1394
|
-
const gitDir = path.join(tempRoot, 'separate-git-metadata');
|
|
1395
|
-
fs.mkdirSync(repo, { recursive: true });
|
|
1396
|
-
await git(repo, 'init', '-b', 'main', '--separate-git-dir', gitDir);
|
|
1397
|
-
const file = path.join(repo, 'file.txt');
|
|
1398
|
-
fs.writeFileSync(file, 'floor\n');
|
|
1399
|
-
await git(repo, 'add', 'file.txt');
|
|
1400
|
-
await git(repo, 'commit', '-m', 'floor');
|
|
1401
|
-
|
|
1402
|
-
const renameSync = fs.renameSync;
|
|
1403
|
-
const linkSync = fs.linkSync;
|
|
1404
|
-
const crossesGitBoundary = (from, to) => (
|
|
1405
|
-
from.startsWith(`${fs.realpathSync(repo)}${path.sep}`)
|
|
1406
|
-
&& to.startsWith(`${fs.realpathSync(gitDir)}${path.sep}`)
|
|
1407
|
-
) || (
|
|
1408
|
-
from.startsWith(`${fs.realpathSync(gitDir)}${path.sep}`)
|
|
1409
|
-
&& to.startsWith(`${fs.realpathSync(repo)}${path.sep}`)
|
|
1410
|
-
);
|
|
1411
|
-
fs.renameSync = (from, to) => {
|
|
1412
|
-
if (crossesGitBoundary(from, to)) {
|
|
1413
|
-
const error = new Error('simulated cross-device workspace move');
|
|
1414
|
-
error.code = 'EXDEV';
|
|
1415
|
-
throw error;
|
|
1416
|
-
}
|
|
1417
|
-
return renameSync(from, to);
|
|
1418
|
-
};
|
|
1419
|
-
fs.linkSync = (from, to) => {
|
|
1420
|
-
if (crossesGitBoundary(from, to)) {
|
|
1421
|
-
const error = new Error('simulated cross-device workspace link');
|
|
1422
|
-
error.code = 'EXDEV';
|
|
1423
|
-
throw error;
|
|
1424
|
-
}
|
|
1425
|
-
return linkSync(from, to);
|
|
1426
|
-
};
|
|
1427
|
-
try {
|
|
1428
|
-
applyFiles(repo, [{
|
|
1429
|
-
path: 'file.txt',
|
|
1430
|
-
content: Buffer.from('official\n'),
|
|
1431
|
-
mode: '100644',
|
|
1432
|
-
expected: {
|
|
1433
|
-
kind: 'file',
|
|
1434
|
-
content: Buffer.from('floor\n'),
|
|
1435
|
-
exec: false,
|
|
1436
|
-
},
|
|
1437
|
-
}], 'separate-git-dir');
|
|
1438
|
-
} finally {
|
|
1439
|
-
fs.renameSync = renameSync;
|
|
1440
|
-
fs.linkSync = linkSync;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'official\n');
|
|
1444
|
-
releaseFileReceipts(repo, 'separate-git-dir');
|
|
1445
|
-
assert.deepEqual(fs.readdirSync(repo).sort(), ['.git', 'file.txt']);
|
|
1446
|
-
});
|