amalgm 0.1.239 → 0.1.241
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/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- 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 +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const CHUNK_BYTES = 512 * 1024;
|
|
6
|
+
|
|
7
|
+
function sha256(bytes) {
|
|
8
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function describeBytes(bytesInput) {
|
|
12
|
+
const bytes = Buffer.isBuffer(bytesInput) ? bytesInput : Buffer.from(bytesInput);
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
|
|
15
|
+
const part = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, bytes.length));
|
|
16
|
+
chunks.push({ sha256: sha256(part), bytes: part.length });
|
|
17
|
+
}
|
|
18
|
+
return chunks;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function manifestChecksum(chunks) {
|
|
22
|
+
return sha256(Buffer.from(JSON.stringify(
|
|
23
|
+
chunks.map((chunk) => ({ sha256: chunk.sha256, bytes: chunk.bytes })),
|
|
24
|
+
)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
CHUNK_BYTES,
|
|
29
|
+
describeBytes,
|
|
30
|
+
manifestChecksum,
|
|
31
|
+
sha256,
|
|
32
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
function syncDirectory(directory) {
|
|
8
|
+
const descriptor = fs.openSync(directory, 'r');
|
|
9
|
+
try {
|
|
10
|
+
fs.fsyncSync(descriptor);
|
|
11
|
+
} finally {
|
|
12
|
+
fs.closeSync(descriptor);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Durable sync state is replaced, never edited in place. After a process
|
|
18
|
+
* death, readers therefore see either the complete old value or the complete
|
|
19
|
+
* new value.
|
|
20
|
+
*/
|
|
21
|
+
function writeFile(file, content, mode = 0o600) {
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
const temporary = path.join(
|
|
24
|
+
path.dirname(file),
|
|
25
|
+
`.${path.basename(file)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`,
|
|
26
|
+
);
|
|
27
|
+
let descriptor = fs.openSync(temporary, 'wx', mode);
|
|
28
|
+
try {
|
|
29
|
+
fs.writeFileSync(descriptor, content);
|
|
30
|
+
fs.fsyncSync(descriptor);
|
|
31
|
+
fs.closeSync(descriptor);
|
|
32
|
+
descriptor = null;
|
|
33
|
+
fs.renameSync(temporary, file);
|
|
34
|
+
syncDirectory(path.dirname(file));
|
|
35
|
+
} finally {
|
|
36
|
+
if (descriptor !== null) fs.closeSync(descriptor);
|
|
37
|
+
if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeJson(file, value, { pretty = false } = {}) {
|
|
42
|
+
writeFile(file, `${JSON.stringify(value, null, pretty ? 2 : 0)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { writeFile, writeJson };
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
21
22
|
const path = require('path');
|
|
22
23
|
const { execFile } = require('child_process');
|
|
23
24
|
const { promisify } = require('util');
|
|
@@ -46,14 +47,15 @@ class GitError extends Error {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
async function git(args, cwd, { okCodes = [0] } = {}) {
|
|
50
|
+
async function git(args, cwd, { okCodes = [0], env = {} } = {}) {
|
|
50
51
|
try {
|
|
51
|
-
const
|
|
52
|
+
const options = {
|
|
52
53
|
cwd,
|
|
53
|
-
env: { ...process.env, ...GIT_ENV },
|
|
54
|
+
env: { ...process.env, ...GIT_ENV, ...env },
|
|
54
55
|
timeout: 15_000,
|
|
55
56
|
maxBuffer: 128 * 1024 * 1024,
|
|
56
|
-
}
|
|
57
|
+
};
|
|
58
|
+
const { stdout } = await execFileAsync('git', args, options);
|
|
57
59
|
return { stdout, exitCode: 0 };
|
|
58
60
|
} catch (error) {
|
|
59
61
|
const exitCode = typeof error.code === 'number' ? error.code : 1;
|
|
@@ -64,6 +66,189 @@ async function git(args, cwd, { okCodes = [0] } = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
function changeIdentity(file) {
|
|
70
|
+
try {
|
|
71
|
+
const stat = fs.lstatSync(file, { bigint: true });
|
|
72
|
+
return {
|
|
73
|
+
dev: stat.dev.toString(),
|
|
74
|
+
ino: stat.ino.toString(),
|
|
75
|
+
mode: stat.mode.toString(),
|
|
76
|
+
size: stat.size.toString(),
|
|
77
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
78
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code === 'ENOENT') return null;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameIdentity(left, right) {
|
|
87
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function indexPath(worktreeRoot) {
|
|
91
|
+
const { stdout } = await git(
|
|
92
|
+
['-C', worktreeRoot, 'rev-parse', '--git-path', 'index'],
|
|
93
|
+
worktreeRoot,
|
|
94
|
+
);
|
|
95
|
+
const value = stdout.trim();
|
|
96
|
+
return path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function worktreePathIdentity(worktreeRoot, relativePath) {
|
|
100
|
+
const parts = relativePath.split('/');
|
|
101
|
+
let current = worktreeRoot;
|
|
102
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
103
|
+
current = path.join(current, parts[index]);
|
|
104
|
+
let stat;
|
|
105
|
+
try {
|
|
106
|
+
stat = fs.lstatSync(current, { bigint: true });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code === 'ENOENT') return { kind: 'absent' };
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (index < parts.length - 1 && !stat.isDirectory()) {
|
|
112
|
+
return { kind: 'unsafe-parent' };
|
|
113
|
+
}
|
|
114
|
+
if (index === parts.length - 1) {
|
|
115
|
+
return {
|
|
116
|
+
kind: stat.isSymbolicLink()
|
|
117
|
+
? 'symlink'
|
|
118
|
+
: stat.isFile()
|
|
119
|
+
? 'file'
|
|
120
|
+
: 'other',
|
|
121
|
+
dev: stat.dev.toString(),
|
|
122
|
+
ino: stat.ino.toString(),
|
|
123
|
+
mode: stat.mode.toString(),
|
|
124
|
+
size: stat.size.toString(),
|
|
125
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
126
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
127
|
+
...(stat.isSymbolicLink() ? { target: fs.readlinkSync(current) } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { kind: 'absent' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function worktreeIdentity(worktreeRoot, paths) {
|
|
135
|
+
return Object.fromEntries(
|
|
136
|
+
paths.map((relativePath) => [
|
|
137
|
+
relativePath,
|
|
138
|
+
worktreePathIdentity(worktreeRoot, relativePath),
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function ignoredNamespacePaths(worktreeRoot) {
|
|
144
|
+
const { stdout } = await git(
|
|
145
|
+
[
|
|
146
|
+
'-C', worktreeRoot,
|
|
147
|
+
'ls-files',
|
|
148
|
+
'--others',
|
|
149
|
+
'--ignored',
|
|
150
|
+
'--exclude-standard',
|
|
151
|
+
'--directory',
|
|
152
|
+
'-z',
|
|
153
|
+
],
|
|
154
|
+
worktreeRoot,
|
|
155
|
+
);
|
|
156
|
+
return stdout.split('\0').filter(Boolean).map((entry) => (
|
|
157
|
+
entry.endsWith('/') ? entry.slice(0, -1) : entry
|
|
158
|
+
));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function namespacePathIgnored(relativePath, ignoredPaths) {
|
|
162
|
+
return ignoredPaths.some((ignored) => (
|
|
163
|
+
relativePath === ignored || relativePath.startsWith(`${ignored}/`)
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function directoryIdentity(absolutePath) {
|
|
168
|
+
const stat = fs.lstatSync(absolutePath, { bigint: true });
|
|
169
|
+
if (!stat.isDirectory()) return null;
|
|
170
|
+
return {
|
|
171
|
+
dev: stat.dev.toString(),
|
|
172
|
+
ino: stat.ino.toString(),
|
|
173
|
+
mode: stat.mode.toString(),
|
|
174
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
175
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Git's untracked listing names files, not the namespace in which new files
|
|
181
|
+
* can appear. Capture the complete non-ignored directory membership before
|
|
182
|
+
* and after the byte reads so delete/list/recreate ABA cannot hide a file.
|
|
183
|
+
* Ignored subtrees are pruned at their boundary, so dependency churn never
|
|
184
|
+
* blocks publication.
|
|
185
|
+
*/
|
|
186
|
+
async function visibleNamespaceIdentity(worktreeRoot) {
|
|
187
|
+
const ignored = await ignoredNamespacePaths(worktreeRoot);
|
|
188
|
+
const identity = {};
|
|
189
|
+
let directories = [{ absolute: worktreeRoot, relative: '' }];
|
|
190
|
+
while (directories.length > 0) {
|
|
191
|
+
const next = [];
|
|
192
|
+
for (const directory of directories) {
|
|
193
|
+
const entries = [];
|
|
194
|
+
try {
|
|
195
|
+
const before = directoryIdentity(directory.absolute);
|
|
196
|
+
if (!before) return { state: 'moving' };
|
|
197
|
+
for (const dirent of fs.readdirSync(directory.absolute, { withFileTypes: true })) {
|
|
198
|
+
if (directory.relative === '' && dirent.name === '.git') continue;
|
|
199
|
+
const relative = directory.relative
|
|
200
|
+
? `${directory.relative}/${dirent.name}`
|
|
201
|
+
: dirent.name;
|
|
202
|
+
if (namespacePathIgnored(relative, ignored)) continue;
|
|
203
|
+
const absolute = path.join(directory.absolute, dirent.name);
|
|
204
|
+
const stat = fs.lstatSync(absolute, { bigint: true });
|
|
205
|
+
const isDirectory = stat.isDirectory();
|
|
206
|
+
const kind = isDirectory
|
|
207
|
+
? 'directory'
|
|
208
|
+
: stat.isSymbolicLink()
|
|
209
|
+
? 'symlink'
|
|
210
|
+
: stat.isFile()
|
|
211
|
+
? 'file'
|
|
212
|
+
: 'other';
|
|
213
|
+
entries.push({
|
|
214
|
+
name: dirent.name,
|
|
215
|
+
kind,
|
|
216
|
+
dev: stat.dev.toString(),
|
|
217
|
+
ino: stat.ino.toString(),
|
|
218
|
+
...(isDirectory ? {
|
|
219
|
+
mode: stat.mode.toString(),
|
|
220
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
221
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
222
|
+
} : {
|
|
223
|
+
mode: stat.mode.toString(),
|
|
224
|
+
size: stat.size.toString(),
|
|
225
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
226
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
227
|
+
...(stat.isSymbolicLink() ? { target: fs.readlinkSync(absolute) } : {}),
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
if (isDirectory && !fs.existsSync(path.join(absolute, '.git'))) {
|
|
231
|
+
next.push({ absolute, relative });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const after = directoryIdentity(directory.absolute);
|
|
235
|
+
if (!after || !sameIdentity(before, after)) return { state: 'moving' };
|
|
236
|
+
identity[directory.relative] = {
|
|
237
|
+
directory: after,
|
|
238
|
+
entries: entries.sort((left, right) => left.name.localeCompare(right.name)),
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
242
|
+
return { state: 'moving' };
|
|
243
|
+
}
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
directories = next.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
248
|
+
}
|
|
249
|
+
return { state: 'ready', value: identity };
|
|
250
|
+
}
|
|
251
|
+
|
|
67
252
|
const NOT_A_REPO_RE = /not a git repository/i;
|
|
68
253
|
|
|
69
254
|
/**
|
|
@@ -339,7 +524,16 @@ async function captureWorktreeDiff(worktreeRoot, { maxBytes = Infinity } = {}) {
|
|
|
339
524
|
['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
340
525
|
worktreeRoot,
|
|
341
526
|
);
|
|
342
|
-
|
|
527
|
+
const untrackedPaths = untracked.stdout.split('\0').filter(Boolean).sort();
|
|
528
|
+
if (untrackedPaths.some((file) => file.endsWith('/'))) {
|
|
529
|
+
return {
|
|
530
|
+
baseOid,
|
|
531
|
+
diff: null,
|
|
532
|
+
reason: 'unsupported-nested-repository',
|
|
533
|
+
bytes,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
for (const file of untrackedPaths) {
|
|
343
537
|
if (bytes > maxBytes) break;
|
|
344
538
|
const creation = await git(
|
|
345
539
|
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
|
|
@@ -377,47 +571,207 @@ function checkpointCaptureResult({
|
|
|
377
571
|
return { baseOid, indexDiff, worktreeDiff, bytes };
|
|
378
572
|
}
|
|
379
573
|
|
|
380
|
-
async function
|
|
574
|
+
async function captureCheckpointOnce(worktreeRoot, { maxBytes = Infinity } = {}) {
|
|
381
575
|
const baseOid = await headOid(worktreeRoot);
|
|
382
576
|
if (!baseOid) return { baseOid: null, indexDiff: null, worktreeDiff: null, reason: 'unborn' };
|
|
577
|
+
const sourceIndex = await indexPath(worktreeRoot);
|
|
578
|
+
const snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-index-snapshot-'));
|
|
579
|
+
const snapshotIndex = path.join(snapshotRoot, 'index');
|
|
580
|
+
try {
|
|
581
|
+
const initialIndexIdentity = changeIdentity(sourceIndex);
|
|
582
|
+
if (!initialIndexIdentity) {
|
|
583
|
+
return {
|
|
584
|
+
baseOid,
|
|
585
|
+
indexDiff: null,
|
|
586
|
+
worktreeDiff: null,
|
|
587
|
+
reason: 'index-missing',
|
|
588
|
+
bytes: 0,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
fs.copyFileSync(sourceIndex, snapshotIndex);
|
|
592
|
+
if (!sameIdentity(initialIndexIdentity, changeIdentity(sourceIndex))) {
|
|
593
|
+
return {
|
|
594
|
+
baseOid,
|
|
595
|
+
indexDiff: null,
|
|
596
|
+
worktreeDiff: null,
|
|
597
|
+
reason: 'observation-moved',
|
|
598
|
+
bytes: 0,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const snapshotEnv = { GIT_INDEX_FILE: snapshotIndex };
|
|
602
|
+
let stagedTree;
|
|
603
|
+
try {
|
|
604
|
+
stagedTree = (await git(
|
|
605
|
+
['-C', worktreeRoot, 'write-tree'],
|
|
606
|
+
worktreeRoot,
|
|
607
|
+
{ env: snapshotEnv },
|
|
608
|
+
)).stdout.trim();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (
|
|
611
|
+
error instanceof GitError
|
|
612
|
+
&& /unmerged|not uptodate|cannot write.*tree/i.test(error.stderr)
|
|
613
|
+
) {
|
|
614
|
+
return {
|
|
615
|
+
baseOid,
|
|
616
|
+
indexDiff: null,
|
|
617
|
+
worktreeDiff: null,
|
|
618
|
+
reason: 'index-unmerged',
|
|
619
|
+
bytes: 0,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
const normalizedIndex = path.join(snapshotRoot, 'normalized-index');
|
|
625
|
+
const normalizedEnv = { GIT_INDEX_FILE: normalizedIndex };
|
|
626
|
+
await git(
|
|
627
|
+
['-C', worktreeRoot, 'read-tree', stagedTree],
|
|
628
|
+
worktreeRoot,
|
|
629
|
+
{ env: normalizedEnv },
|
|
630
|
+
);
|
|
631
|
+
const tracked = await git(
|
|
632
|
+
['-C', worktreeRoot, 'ls-files', '-z'],
|
|
633
|
+
worktreeRoot,
|
|
634
|
+
{ env: normalizedEnv },
|
|
635
|
+
);
|
|
636
|
+
const trackedPaths = tracked.stdout.split('\0').filter(Boolean);
|
|
637
|
+
const initialNamespace = await visibleNamespaceIdentity(worktreeRoot);
|
|
638
|
+
if (initialNamespace.state !== 'ready') {
|
|
639
|
+
return {
|
|
640
|
+
baseOid,
|
|
641
|
+
indexDiff: null,
|
|
642
|
+
worktreeDiff: null,
|
|
643
|
+
reason: 'observation-moved',
|
|
644
|
+
bytes: 0,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const untracked = await git(
|
|
648
|
+
['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
649
|
+
worktreeRoot,
|
|
650
|
+
{ env: normalizedEnv },
|
|
651
|
+
);
|
|
652
|
+
const untrackedPaths = untracked.stdout.split('\0').filter(Boolean).sort();
|
|
653
|
+
if (untrackedPaths.some((file) => file.endsWith('/'))) {
|
|
654
|
+
return {
|
|
655
|
+
baseOid,
|
|
656
|
+
indexDiff: null,
|
|
657
|
+
worktreeDiff: null,
|
|
658
|
+
reason: 'unsupported-nested-repository',
|
|
659
|
+
bytes: 0,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const observedPaths = [...new Set([...trackedPaths, ...untrackedPaths])].sort();
|
|
663
|
+
const initialWorktreeIdentity = worktreeIdentity(worktreeRoot, observedPaths);
|
|
383
664
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
worktreeRoot,
|
|
387
|
-
);
|
|
388
|
-
const unstaged = await git(
|
|
389
|
-
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--binary', '--full-index'],
|
|
390
|
-
worktreeRoot,
|
|
391
|
-
);
|
|
392
|
-
const worktreeParts = [unstaged.stdout];
|
|
393
|
-
let bytes = Buffer.byteLength(staged.stdout) + Buffer.byteLength(unstaged.stdout);
|
|
394
|
-
const untracked = await git(
|
|
395
|
-
['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
396
|
-
worktreeRoot,
|
|
397
|
-
);
|
|
398
|
-
for (const file of untracked.stdout.split('\0').filter(Boolean).sort()) {
|
|
399
|
-
if (bytes > maxBytes) break;
|
|
400
|
-
const creation = await git(
|
|
401
|
-
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
|
|
665
|
+
const staged = await git(
|
|
666
|
+
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', 'HEAD', stagedTree, '--binary', '--full-index'],
|
|
402
667
|
worktreeRoot,
|
|
403
|
-
{
|
|
668
|
+
{ env: normalizedEnv },
|
|
404
669
|
);
|
|
405
|
-
|
|
406
|
-
|
|
670
|
+
const unstaged = await git(
|
|
671
|
+
['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--binary', '--full-index'],
|
|
672
|
+
worktreeRoot,
|
|
673
|
+
{ env: normalizedEnv },
|
|
674
|
+
);
|
|
675
|
+
const worktreeParts = [unstaged.stdout];
|
|
676
|
+
let bytes = Buffer.byteLength(staged.stdout) + Buffer.byteLength(unstaged.stdout);
|
|
677
|
+
for (const file of untrackedPaths) {
|
|
678
|
+
if (bytes > maxBytes) break;
|
|
679
|
+
const creation = await git(
|
|
680
|
+
[
|
|
681
|
+
'-C', worktreeRoot, ...PATCH_ARGS,
|
|
682
|
+
'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file,
|
|
683
|
+
],
|
|
684
|
+
worktreeRoot,
|
|
685
|
+
{ okCodes: [0, 1], env: normalizedEnv },
|
|
686
|
+
);
|
|
687
|
+
worktreeParts.push(creation.stdout);
|
|
688
|
+
bytes += Buffer.byteLength(creation.stdout);
|
|
689
|
+
}
|
|
690
|
+
const observedOid = await headOid(worktreeRoot);
|
|
691
|
+
const finalWorktreeIdentity = worktreeIdentity(worktreeRoot, observedPaths);
|
|
692
|
+
const finalUntracked = await git(
|
|
693
|
+
['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
|
|
694
|
+
worktreeRoot,
|
|
695
|
+
{ env: normalizedEnv },
|
|
696
|
+
);
|
|
697
|
+
const finalNamespace = await visibleNamespaceIdentity(worktreeRoot);
|
|
698
|
+
if (
|
|
699
|
+
!sameIdentity(initialIndexIdentity, changeIdentity(sourceIndex))
|
|
700
|
+
|| !sameIdentity(initialWorktreeIdentity, finalWorktreeIdentity)
|
|
701
|
+
|| untracked.stdout !== finalUntracked.stdout
|
|
702
|
+
|| finalNamespace.state !== 'ready'
|
|
703
|
+
|| !sameIdentity(initialNamespace.value, finalNamespace.value)
|
|
704
|
+
) {
|
|
705
|
+
return {
|
|
706
|
+
baseOid,
|
|
707
|
+
indexDiff: null,
|
|
708
|
+
worktreeDiff: null,
|
|
709
|
+
reason: 'observation-moved',
|
|
710
|
+
bytes,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
return checkpointCaptureResult({
|
|
714
|
+
baseOid,
|
|
715
|
+
observedOid,
|
|
716
|
+
indexDiff: staged.stdout,
|
|
717
|
+
worktreeDiff: worktreeParts.join(''),
|
|
718
|
+
bytes,
|
|
719
|
+
maxBytes,
|
|
720
|
+
});
|
|
721
|
+
} finally {
|
|
722
|
+
fs.rmSync(snapshotRoot, { recursive: true, force: true });
|
|
407
723
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function stableCheckpointCapture(first, second) {
|
|
727
|
+
if (JSON.stringify(first) === JSON.stringify(second)) return second;
|
|
728
|
+
return {
|
|
729
|
+
baseOid: second?.baseOid ?? first?.baseOid ?? null,
|
|
730
|
+
indexDiff: null,
|
|
731
|
+
worktreeDiff: null,
|
|
732
|
+
reason: 'observation-moved',
|
|
733
|
+
bytes: Math.max(
|
|
734
|
+
Number.isFinite(first?.bytes) ? first.bytes : 0,
|
|
735
|
+
Number.isFinite(second?.bytes) ? second.bytes : 0,
|
|
736
|
+
),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function captureStableCheckpoint(read, attempts = 4) {
|
|
741
|
+
if (!Number.isInteger(attempts) || attempts < 2) {
|
|
742
|
+
throw new Error('checkpoint capture requires at least two observations');
|
|
743
|
+
}
|
|
744
|
+
let previous = await read();
|
|
745
|
+
let compared = previous;
|
|
746
|
+
for (let attempt = 1; attempt < attempts; attempt += 1) {
|
|
747
|
+
const current = await read();
|
|
748
|
+
compared = stableCheckpointCapture(previous, current);
|
|
749
|
+
if (compared.reason !== 'observation-moved') return compared;
|
|
750
|
+
previous = current;
|
|
751
|
+
}
|
|
752
|
+
return compared;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* A checkpoint is a fact about HEAD, index, and worktree at one stable
|
|
757
|
+
* instant. Individual Git reads cannot lock all three layers together, so a
|
|
758
|
+
* capture is usable only after two consecutive complete captures are
|
|
759
|
+
* byte-identical. A bounded retry absorbs filesystem metadata settling after
|
|
760
|
+
* a save; alternating or continuously moving state still returns "moving."
|
|
761
|
+
* Mixed layers are never promoted to provenance.
|
|
762
|
+
*/
|
|
763
|
+
async function captureCheckpoint(worktreeRoot, options = {}) {
|
|
764
|
+
return captureStableCheckpoint(
|
|
765
|
+
() => captureCheckpointOnce(worktreeRoot, options),
|
|
766
|
+
);
|
|
417
767
|
}
|
|
418
768
|
|
|
419
769
|
module.exports = {
|
|
420
|
-
_test: {
|
|
770
|
+
_test: {
|
|
771
|
+
captureStableCheckpoint,
|
|
772
|
+
checkpointCaptureResult,
|
|
773
|
+
stableCheckpointCapture,
|
|
774
|
+
},
|
|
421
775
|
GitError,
|
|
422
776
|
captureCheckpoint,
|
|
423
777
|
captureWorktreeDiff,
|