amalgm 0.1.161 → 0.1.162

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.
Files changed (42) hide show
  1. package/lib/shared-realtime-tunnel.js +30 -1
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
  4. package/runtime/scripts/amalgm-mcp/index.js +3 -0
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
  7. package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
  8. package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
  9. package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
  10. package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
  11. package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
  12. package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
  13. package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
  14. package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
  15. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
  16. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
  20. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
  21. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
  26. package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
  27. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
  28. package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
  29. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
  31. package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
  32. package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
  33. package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
  35. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
  36. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
  37. package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
  38. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
  39. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
  40. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
  41. package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
  42. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
@@ -20,11 +20,11 @@ const {
20
20
  revealOperation,
21
21
  } = require('./mutation-contracts');
22
22
  const { trace } = require('./trace');
23
+ const { isCloudResourceId } = require('./cloud-resource-id');
23
24
 
24
25
  const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
25
26
  const DEFAULT_CLAIM_LIMIT = 100;
26
27
  const DEFAULT_CLAIM_LEASE_MS = 30_000;
27
- const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
28
28
  const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
29
29
  const OPERATION_KIND = /^[a-z][a-z0-9._:-]{0,63}$/;
30
30
  const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
@@ -108,8 +108,8 @@ function normalizeMutationInput(input) {
108
108
  const sharedResourceId = cloudResourceInput == null
109
109
  ? null
110
110
  : cleanText(cloudResourceInput, 'cloudResourceId', 132);
111
- if (sharedResourceId && !CLOUD_RESOURCE_ID.test(sharedResourceId)) {
112
- throw invalid('cloudResourceId must be an opaque id beginning with shr_ or pvr_');
111
+ if (sharedResourceId && !isCloudResourceId(sharedResourceId)) {
112
+ throw invalid('cloudResourceId must be a node UUID or legacy cloud id');
113
113
  }
114
114
  if (sharedResourceId && channelId !== `resource:${sharedResourceId}`) {
115
115
  throw invalid('a cloud mutation channelId must be resource:<cloudResourceId>');
@@ -803,7 +803,7 @@ function listMutations(options = {}) {
803
803
  */
804
804
  function listPendingCloudMutations(resourceIdInput, options = {}) {
805
805
  const resourceId = cleanText(resourceIdInput, 'resourceId', 132);
806
- if (!CLOUD_RESOURCE_ID.test(resourceId)) {
806
+ if (!isCloudResourceId(resourceId)) {
807
807
  throw invalid('resourceId must be an opaque cloud resource id');
808
808
  }
809
809
  const database = options.database || openLocalDb();
@@ -6,8 +6,8 @@ const { cachedStatement, openLocalDb } = require('./db');
6
6
  const { appendStateEvent } = require('./events');
7
7
  const { registerReplica } = require('./replicas');
8
8
  const { trace } = require('./trace');
9
+ const { isSharedResourceId } = require('./cloud-resource-id');
9
10
 
10
- const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
11
11
  const PROMOTION_ID = /^[A-Za-z0-9_-]{16,160}$/;
12
12
 
13
13
  function invalid(message, statusCode = 400) {
@@ -52,7 +52,7 @@ function enqueuePromotion(input, options = {}) {
52
52
  const promotionId = requiredText(input?.promotionId, 'promotionId', 160);
53
53
  const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
54
54
  if (!PROMOTION_ID.test(promotionId)) throw invalid('promotionId has an invalid format');
55
- if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
55
+ if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
56
56
  const localResource = requiredText(input?.localResource, 'localResource');
57
57
  const localPath = requiredText(input?.localPath, 'localPath', 4096);
58
58
  const contract = requiredText(input?.contract || 'text-yjs@1', 'contract', 128);
@@ -10,8 +10,8 @@
10
10
 
11
11
  const { cachedStatement, openLocalDb } = require('./db');
12
12
  const { trace } = require('./trace');
13
+ const { isCloudResourceId } = require('./cloud-resource-id');
13
14
 
14
- const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
15
15
  const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
16
16
 
17
17
  function invalid(message, statusCode = 400) {
@@ -55,8 +55,8 @@ function normalizeReplica(row) {
55
55
 
56
56
  function registerReplica(input, options = {}) {
57
57
  const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
58
- if (!CLOUD_RESOURCE_ID.test(resourceId)) {
59
- throw invalid('resourceId must be an opaque id beginning with shr_ or pvr_');
58
+ if (!isCloudResourceId(resourceId)) {
59
+ throw invalid('resourceId must be a node UUID or legacy cloud id');
60
60
  }
61
61
  const localResource = requiredText(input?.localResource, 'localResource');
62
62
  const localPath = requiredText(input?.localPath, 'localPath', 4096);
@@ -177,8 +177,9 @@ function refreshReplicaAuthority(input, options = {}) {
177
177
  throw invalid(`shared resource ${resourceId} contract changed`, 409);
178
178
  }
179
179
  if (authorityEpoch < Number(row.authority_epoch)) {
180
- return normalizeReplica(row);
180
+ return { ...normalizeReplica(row), authorityChanged: false };
181
181
  }
182
+ const authorityChanged = authorityEpoch > Number(row.authority_epoch);
182
183
  cachedStatement(database, `
183
184
  UPDATE shared_resource_replicas
184
185
  SET authority_epoch = ?,
@@ -190,7 +191,7 @@ function refreshReplicaAuthority(input, options = {}) {
190
191
  updated_at = ?
191
192
  WHERE resource_id = ?
192
193
  `).run(authorityEpoch, headVersion, headVersion, new Date().toISOString(), resourceId);
193
- return getReplicaByResourceId(resourceId, { database });
194
+ return { ...getReplicaByResourceId(resourceId, { database }), authorityChanged };
194
195
  }
195
196
 
196
197
  /**
@@ -71,29 +71,24 @@ async function handleReplicaRehydrate(body, sendJson) {
71
71
  }
72
72
  }
73
73
 
74
+ async function handleWorkspaceContentDiscover(body, sendJson) {
75
+ try {
76
+ const attachments = require('../workspace/tree-cloud').enqueueWorkspaceContentAttachments(
77
+ body?.workspaceId,
78
+ body?.contentResources,
79
+ );
80
+ sendJson(attachments.length ? 202 : 200, { attachments });
81
+ } catch (error) {
82
+ sendHandlerError(error, sendJson);
83
+ }
84
+ }
85
+
74
86
  async function handlePromotionStart(body, sendJson) {
75
87
  try {
76
- // One owner per file (workspace-sync.md §1). A file tracked by a
77
- // repository is git-owned: it syncs through commits on the workspace
78
- // rail, and it may join the doc rail only via a future explicit
79
- // ownership handoff — never by ambient promotion.
80
- if (typeof body?.path === 'string' && body.path) {
81
- const owner = await require('../workspace/git').gitOwnerOf(body.path);
82
- if (owner) {
83
- throw Object.assign(
84
- new Error(
85
- `${body.path} is git-owned: tracked by the repository at ${owner.toplevel}. `
86
- + 'Repository files sync through git (commits are the sync points), not the doc rail. '
87
- + 'If this file truly belongs on the rail, untrack it first (git rm --cached <file>).',
88
- ),
89
- { statusCode: 409, code: 'file_git_owned' },
90
- );
91
- }
92
- }
93
88
  const opened = require('./docs').openDoc(body?.path);
94
89
  const result = require('./promotions').enqueuePromotion({
95
90
  promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
96
- resourceId: body?.resourceId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
91
+ resourceId: body?.resourceId || opened.nodeId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
97
92
  localResource: opened.resource,
98
93
  localPath: opened.path,
99
94
  contract: body?.contract || 'text-yjs@1',
@@ -161,15 +156,22 @@ async function handlePromotionFail(body, sendJson) {
161
156
 
162
157
  async function handleAttachmentStart(body, sendJson) {
163
158
  try {
164
- const opened = require('./docs').openDoc(body?.path);
165
- if (require('./docs').getDocText(opened.path).length > 0) {
159
+ const tree = body?.contract === 'workspace-tree@1';
160
+ const localPath = tree ? require('path').resolve(body?.path || '') : require('./docs').openDoc(body?.path).path;
161
+ if (tree) {
162
+ const stats = fs.statSync(localPath);
163
+ if (!stats.isDirectory() || fs.readdirSync(localPath).length > 0) {
164
+ sendJson(409, { error: 'cloud workspace attachment requires an empty folder' });
165
+ return;
166
+ }
167
+ } else if (require('./docs').getDocText(localPath).length > 0) {
166
168
  sendJson(409, { error: 'cloud attachment requires an empty local text file' });
167
169
  return;
168
170
  }
169
171
  const result = require('./attachments').enqueueAttachment({
170
172
  attachmentId: body?.attachmentId || `att_${crypto.randomBytes(16).toString('base64url')}`,
171
173
  resourceId: body?.resourceId,
172
- localPath: opened.path,
174
+ localPath,
173
175
  });
174
176
  sendJson(result.duplicate ? 200 : 202, result);
175
177
  } catch (error) {
@@ -270,7 +272,9 @@ async function handleMutationIngest(body, sendJson) {
270
272
  const contract = body?.envelope?.contract;
271
273
  const result = contract === 'browser-cookie-jar@1'
272
274
  ? require('../browser/cookie-jar').applyCloudCookieMutation(body)
273
- : require('./docs').applyCloudDocMutation(body);
275
+ : contract === 'workspace-tree@1'
276
+ ? require('../workspace/tree-store').applyCloudTreeMutation(body)
277
+ : require('./docs').applyCloudDocMutation(body);
274
278
  sendJson(200, result);
275
279
  } catch (error) {
276
280
  sendHandlerError(error, sendJson);
@@ -296,4 +300,5 @@ module.exports = {
296
300
  handleReplicaList,
297
301
  handleReplicaRegister,
298
302
  handleReplicaRehydrate,
303
+ handleWorkspaceContentDiscover,
299
304
  };
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
 
3
- // One owner per file (workspace-sync.md §1): a git-tracked file syncs
4
- // through git, and the doc rail must refuse to promote it. Untracked
5
- // files inside a repo and files outside any repo have no git owner and
6
- // promote as before.
3
+ // Git owns commits, refs, the index, and execution. Workspace collaboration
4
+ // owns included working-tree content, so tracked text files use the same live
5
+ // document rail as every other enrolled text file.
7
6
 
8
7
  const test = require('node:test');
9
8
  const assert = require('node:assert/strict');
@@ -52,7 +51,7 @@ async function promote(filePath) {
52
51
  return sent;
53
52
  }
54
53
 
55
- test('a git-tracked file is refused: git owns it', async () => {
54
+ test('a git-tracked text file promotes through the document rail', async () => {
56
55
  const repo = path.join(tempRoot, 'repo');
57
56
  fs.mkdirSync(repo, { recursive: true });
58
57
  await run(repo, 'init', '-b', 'main');
@@ -62,10 +61,7 @@ test('a git-tracked file is refused: git owns it', async () => {
62
61
  await run(repo, 'commit', '-m', 'initial');
63
62
 
64
63
  const { statusCode, payload } = await promote(tracked);
65
- assert.equal(statusCode, 409);
66
- assert.equal(payload.code, 'file_git_owned');
67
- assert.match(payload.error, /tracked by the repository at /);
68
- assert.ok(payload.error.includes(fs.realpathSync(repo)), 'the error names the owning repo');
64
+ assert.equal(statusCode, 202, JSON.stringify(payload));
69
65
  });
70
66
 
71
67
  test('an untracked file inside a repo has no git owner and promotes', async () => {
@@ -0,0 +1,120 @@
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
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-access-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+ process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
12
+ process.env.AMALGM_COMPUTER_ID = 'access-computer';
13
+ const ownerId = '11111111-1111-4111-8111-111111111111';
14
+ const memberId = '22222222-2222-4222-8222-222222222222';
15
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
16
+ fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
17
+ fs.writeFileSync(path.join(process.env.AMALGM_DIR, 'computer.json'), JSON.stringify({
18
+ computer_id: 'access-computer',
19
+ user_id: ownerId,
20
+ }));
21
+
22
+ const { closeLocalDb, openLocalDb } = require('../state/db');
23
+ const docs = require('../state/docs');
24
+ const { runLegacyMigrations } = require('../state/migrations');
25
+ const { upsertWorkspace } = require('../workspace/store');
26
+ const {
27
+ commitPathCreate,
28
+ enrollWorkspace,
29
+ nodeForPath,
30
+ reconcileWorkspaceTree,
31
+ requirePathCapability,
32
+ } = require('../workspace/tree-store');
33
+ const {
34
+ clearNodePermission,
35
+ resolveAccess,
36
+ revokeWorkspaceUser,
37
+ setNodePermission,
38
+ workspaceEpoch,
39
+ } = require('../workspace/access-store');
40
+
41
+ test.after(() => {
42
+ docs.closeAllDocs();
43
+ closeLocalDb();
44
+ fs.rmSync(tempRoot, { recursive: true, force: true });
45
+ });
46
+
47
+ test('explicit child grants override inheritance and revoke-all is one epoch change', () => {
48
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'project');
49
+ const folder = path.join(root, 'folder');
50
+ const file = path.join(folder, 'note.md');
51
+ fs.mkdirSync(folder, { recursive: true });
52
+ fs.writeFileSync(file, 'hello\n');
53
+ const workspace = upsertWorkspace({ path: root, name: 'Project' });
54
+ enrollWorkspace(workspace.id);
55
+ const folderId = nodeForPath(folder).node.id;
56
+ const fileId = nodeForPath(file).node.id;
57
+
58
+ setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: workspace.id, role: 'viewer' });
59
+ assert.equal(resolveAccess(fileId, memberId).role, 'viewer');
60
+ setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: folderId, role: 'editor' });
61
+ assert.equal(resolveAccess(fileId, memberId).role, 'editor');
62
+ setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: fileId, role: 'no_access' });
63
+ assert.equal(resolveAccess(fileId, memberId).role, 'no_access');
64
+ clearNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: fileId });
65
+ assert.equal(resolveAccess(fileId, memberId).role, 'editor');
66
+
67
+ const before = workspaceEpoch(workspace.id);
68
+ const revoked = revokeWorkspaceUser({ actorUserId: ownerId, userId: memberId, workspaceId: workspace.id });
69
+ assert.equal(revoked.revoked, 2);
70
+ assert.equal(revoked.authorityEpoch, before + 1);
71
+ assert.equal(resolveAccess(fileId, memberId).role, null);
72
+ });
73
+
74
+ test('migration backfills the signed-in owner only for locally owned workspaces', () => {
75
+ const localRoot = path.join(process.env.AMALGM_WORKSPACES_DIR, 'backfill-local');
76
+ const cloudRoot = path.join(process.env.AMALGM_WORKSPACES_DIR, 'backfill-cloud');
77
+ fs.mkdirSync(localRoot);
78
+ fs.mkdirSync(cloudRoot);
79
+ const local = upsertWorkspace({ path: localRoot, name: 'Local', source: 'computer' });
80
+ const cloud = upsertWorkspace(
81
+ { path: cloudRoot, name: 'Cloud', source: 'cloud' },
82
+ { seedOwner: false },
83
+ );
84
+ const database = openLocalDb();
85
+ database.prepare('DELETE FROM workspace_node_permissions WHERE workspace_id = ?').run(local.id);
86
+
87
+ runLegacyMigrations(database);
88
+
89
+ assert.equal(resolveAccess(local.id, ownerId, { database }).role, 'owner');
90
+ assert.equal(resolveAccess(cloud.id, ownerId, { database }).role, null);
91
+ });
92
+
93
+ test('viewer disk changes are held instead of entering the mutation journal', () => {
94
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'viewer-projection');
95
+ fs.mkdirSync(root);
96
+ const seededPath = path.join(root, 'seeded.txt');
97
+ fs.writeFileSync(seededPath, 'original\n');
98
+ const workspace = upsertWorkspace(
99
+ { path: root, name: 'Viewer projection', source: 'cloud' },
100
+ { seedOwner: false },
101
+ );
102
+ enrollWorkspace(workspace.id);
103
+ const database = openLocalDb();
104
+ database.prepare(`
105
+ INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
106
+ VALUES (?, ?, ?, 'viewer')
107
+ `).run(workspace.id, workspace.id, ownerId);
108
+
109
+ const observedPath = path.join(root, 'from-shell.txt');
110
+ fs.writeFileSync(observedPath, 'local-only\n');
111
+ const reconciled = reconcileWorkspaceTree(workspace.id, { database });
112
+ assert.equal(reconciled.operations.length, 0);
113
+ assert.equal(reconciled.held.length, 1);
114
+ assert.equal(reconciled.held[0].operation.kind, 'tree.create');
115
+ assert.equal(nodeForPath(observedPath, { database }), null);
116
+ assert.throws(() => commitPathCreate(path.join(root, 'from-api.txt'), 'file', { database }), /forbidden/);
117
+ assert.throws(() => requirePathCapability(root, 'edit', { database }), /forbidden/);
118
+ assert.throws(() => docs.writeDocText(seededPath, 'viewer edit\n'), /forbidden/);
119
+ assert.equal(docs.getDocText(seededPath), 'original\n');
120
+ });
@@ -0,0 +1,137 @@
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 } = require('child_process');
9
+ const { promisify } = require('util');
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-checkpoint-test-'));
14
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
15
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
16
+ process.env.AMALGM_COMPUTER_ID = 'machine-a';
17
+ process.env.AMALGM_DEVICE_ID = 'device-machine-a';
18
+
19
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
20
+ fs.writeFileSync(
21
+ path.join(process.env.AMALGM_DIR, 'computer.json'),
22
+ JSON.stringify({ computer_id: 'machine-a', user_id: '11111111-1111-4111-8111-111111111111' }),
23
+ );
24
+
25
+ const { closeLocalDb } = require('../state/db');
26
+ const { captureWorktreeDiff } = require('../workspace/git');
27
+ const { applyPatch } = require('../workspace/transition');
28
+ const { checkpointResourceId, revisionOf } = require('../workspace/checkpoint');
29
+
30
+ test.after(() => {
31
+ closeLocalDb();
32
+ fs.rmSync(tempRoot, { recursive: true, force: true });
33
+ });
34
+
35
+ async function run(cwd, ...args) {
36
+ await execFileAsync(
37
+ 'git',
38
+ ['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
39
+ { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
40
+ );
41
+ }
42
+
43
+ async function makeRepoPair() {
44
+ const stamp = Math.random().toString(36).slice(2, 8);
45
+ const author = path.join(tempRoot, `author-${stamp}`);
46
+ fs.mkdirSync(author, { recursive: true });
47
+ await run(author, 'init', '-b', 'main');
48
+ fs.writeFileSync(path.join(author, 'README.md'), '# app\nline two\n');
49
+ fs.mkdirSync(path.join(author, 'src'));
50
+ fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(1);\n');
51
+ fs.writeFileSync(path.join(author, '.gitignore'), 'node_modules/\n');
52
+ fs.writeFileSync(path.join(author, 'LICENSE'), 'MIT\n');
53
+ await run(author, 'add', '.');
54
+ await run(author, 'commit', '-m', 'initial');
55
+ const receiver = path.join(tempRoot, `receiver-${stamp}`);
56
+ await run(tempRoot, 'clone', '-q', author, receiver);
57
+ return { author, receiver };
58
+ }
59
+
60
+ test('a dirty worktree round-trips through capture and apply, gitignore filtering included', async () => {
61
+ const { author, receiver } = await makeRepoPair();
62
+
63
+ // Every dirty shape at once: tracked edit, staged edit, deletion, new
64
+ // text file in a new folder, binary file, and ignored junk.
65
+ fs.appendFileSync(path.join(author, 'README.md'), 'edited\n');
66
+ fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(2);\n');
67
+ await run(author, 'add', 'src/index.js');
68
+ fs.rmSync(path.join(author, 'LICENSE'));
69
+ fs.mkdirSync(path.join(author, 'lib'));
70
+ fs.writeFileSync(path.join(author, 'lib', 'util.js'), 'module.exports = 42;\n');
71
+ fs.writeFileSync(path.join(author, 'blob.bin'), Buffer.from([0, 1, 2, 255, 0, 7]));
72
+ fs.mkdirSync(path.join(author, 'node_modules', 'junk'), { recursive: true });
73
+ fs.writeFileSync(path.join(author, 'node_modules', 'junk', 'x.js'), 'never travels\n');
74
+
75
+ const captured = await captureWorktreeDiff(author);
76
+ assert.ok(captured.diff.length > 0);
77
+ assert.ok(!captured.diff.includes('node_modules'), 'ignored files must never enter a checkpoint');
78
+
79
+ const patchFile = path.join(tempRoot, 'roundtrip.patch');
80
+ fs.writeFileSync(patchFile, captured.diff);
81
+ await applyPatch(receiver, patchFile);
82
+
83
+ assert.equal(
84
+ fs.readFileSync(path.join(receiver, 'README.md'), 'utf8'),
85
+ fs.readFileSync(path.join(author, 'README.md'), 'utf8'),
86
+ );
87
+ assert.equal(
88
+ fs.readFileSync(path.join(receiver, 'src', 'index.js'), 'utf8'),
89
+ 'console.log(2);\n',
90
+ );
91
+ assert.equal(
92
+ fs.readFileSync(path.join(receiver, 'lib', 'util.js'), 'utf8'),
93
+ 'module.exports = 42;\n',
94
+ );
95
+ assert.deepEqual(
96
+ fs.readFileSync(path.join(receiver, 'blob.bin')),
97
+ Buffer.from([0, 1, 2, 255, 0, 7]),
98
+ );
99
+ assert.equal(fs.existsSync(path.join(receiver, 'LICENSE')), false, 'deletions travel');
100
+ assert.equal(fs.existsSync(path.join(receiver, 'node_modules')), false);
101
+
102
+ // And the reverse of the same patch restores the receiver to clean.
103
+ await applyPatch(receiver, patchFile, { reverse: true });
104
+ const after = await captureWorktreeDiff(receiver);
105
+ assert.equal(after.diff, '');
106
+ });
107
+
108
+ test('capture is stable: two reads of an untouched tree produce the same revision', async () => {
109
+ const { author } = await makeRepoPair();
110
+ fs.appendFileSync(path.join(author, 'README.md'), 'same edit\n');
111
+ const first = await captureWorktreeDiff(author);
112
+ const second = await captureWorktreeDiff(author);
113
+ assert.equal(first.diff, second.diff);
114
+ assert.equal(revisionOf(first.baseOid, first.diff), revisionOf(second.baseOid, second.diff));
115
+ });
116
+
117
+ test('a clean tree captures as an empty patch, and oversized state is a named refusal', async () => {
118
+ const { author } = await makeRepoPair();
119
+ const clean = await captureWorktreeDiff(author);
120
+ assert.equal(clean.diff, '');
121
+
122
+ fs.writeFileSync(path.join(author, 'big.txt'), 'x'.repeat(4096));
123
+ const bounded = await captureWorktreeDiff(author, { maxBytes: 100 });
124
+ assert.equal(bounded.diff, null);
125
+ assert.equal(bounded.reason, 'too-large');
126
+ });
127
+
128
+ test('checkpoint resource ids are stable per (user, machine, repo) and distinct across each', () => {
129
+ const user = '11111111-1111-4111-8111-111111111111';
130
+ const id = checkpointResourceId('m1', 'https://github.com/a/b', user);
131
+ assert.equal(id, checkpointResourceId('m1', 'https://github.com/a/b', user));
132
+ assert.match(id, /^shr_[A-Za-z0-9_-]{16,128}$/);
133
+ assert.notEqual(id, checkpointResourceId('m2', 'https://github.com/a/b', user));
134
+ assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/c', user));
135
+ assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/b', 'other-user'));
136
+ assert.equal(checkpointResourceId(null, 'x', user), null);
137
+ });