pushwork 2.0.0-a.sub.1 → 2.0.0-preview

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 (251) hide show
  1. package/dist/branches.d.ts +19 -0
  2. package/dist/branches.d.ts.map +1 -0
  3. package/dist/branches.js +111 -0
  4. package/dist/branches.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +238 -272
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +17 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +84 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/fs-tree.d.ts +6 -0
  14. package/dist/fs-tree.d.ts.map +1 -0
  15. package/dist/fs-tree.js +99 -0
  16. package/dist/fs-tree.js.map +1 -0
  17. package/dist/ignore.d.ts +6 -0
  18. package/dist/ignore.d.ts.map +1 -0
  19. package/dist/ignore.js +74 -0
  20. package/dist/ignore.js.map +1 -0
  21. package/dist/index.d.ts +8 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +34 -4
  24. package/dist/index.js.map +1 -1
  25. package/dist/log.d.ts +3 -0
  26. package/dist/log.d.ts.map +1 -0
  27. package/dist/log.js +14 -0
  28. package/dist/log.js.map +1 -0
  29. package/dist/pushwork.d.ts +115 -0
  30. package/dist/pushwork.d.ts.map +1 -0
  31. package/dist/pushwork.js +918 -0
  32. package/dist/pushwork.js.map +1 -0
  33. package/dist/repo.d.ts +14 -0
  34. package/dist/repo.d.ts.map +1 -0
  35. package/dist/repo.js +60 -0
  36. package/dist/repo.js.map +1 -0
  37. package/dist/shapes/custom.d.ts +3 -0
  38. package/dist/shapes/custom.d.ts.map +1 -0
  39. package/dist/shapes/custom.js +57 -0
  40. package/dist/shapes/custom.js.map +1 -0
  41. package/dist/shapes/file.d.ts +20 -0
  42. package/dist/shapes/file.d.ts.map +1 -0
  43. package/dist/shapes/file.js +140 -0
  44. package/dist/shapes/file.js.map +1 -0
  45. package/dist/shapes/index.d.ts +10 -0
  46. package/dist/shapes/index.d.ts.map +1 -0
  47. package/dist/shapes/index.js +35 -0
  48. package/dist/shapes/index.js.map +1 -0
  49. package/dist/shapes/patchwork-folder.d.ts +3 -0
  50. package/dist/shapes/patchwork-folder.d.ts.map +1 -0
  51. package/dist/shapes/patchwork-folder.js +160 -0
  52. package/dist/shapes/patchwork-folder.js.map +1 -0
  53. package/dist/shapes/types.d.ts +37 -0
  54. package/dist/shapes/types.d.ts.map +1 -0
  55. package/dist/shapes/types.js +52 -0
  56. package/dist/shapes/types.js.map +1 -0
  57. package/dist/shapes/vfs.d.ts +3 -0
  58. package/dist/shapes/vfs.d.ts.map +1 -0
  59. package/dist/shapes/vfs.js +88 -0
  60. package/dist/shapes/vfs.js.map +1 -0
  61. package/dist/stash.d.ts +23 -0
  62. package/dist/stash.d.ts.map +1 -0
  63. package/dist/stash.js +118 -0
  64. package/dist/stash.js.map +1 -0
  65. package/flake.lock +128 -0
  66. package/flake.nix +66 -0
  67. package/package.json +15 -48
  68. package/patches/@automerge__automerge-repo@2.6.0-subduction.15.patch +26 -0
  69. package/pnpm-workspace.yaml +5 -0
  70. package/src/branches.ts +93 -0
  71. package/src/cli.ts +258 -408
  72. package/src/config.ts +64 -0
  73. package/src/fs-tree.ts +70 -0
  74. package/src/ignore.ts +33 -0
  75. package/src/index.ts +38 -4
  76. package/src/log.ts +8 -0
  77. package/src/pushwork.ts +1055 -0
  78. package/src/repo.ts +76 -0
  79. package/src/shapes/custom.ts +29 -0
  80. package/src/shapes/file.ts +115 -0
  81. package/src/shapes/index.ts +19 -0
  82. package/src/shapes/patchwork-folder.ts +156 -0
  83. package/src/shapes/types.ts +79 -0
  84. package/src/shapes/vfs.ts +93 -0
  85. package/src/stash.ts +106 -0
  86. package/test/integration/branches.test.ts +389 -0
  87. package/test/integration/pushwork.test.ts +547 -0
  88. package/test/setup.ts +29 -0
  89. package/test/unit/doc-shape.test.ts +612 -0
  90. package/tsconfig.json +2 -3
  91. package/vitest.config.ts +14 -0
  92. package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +0 -248
  93. package/CLAUDE.md +0 -141
  94. package/README.md +0 -221
  95. package/babel.config.js +0 -5
  96. package/dist/cli/commands.d.ts +0 -71
  97. package/dist/cli/commands.d.ts.map +0 -1
  98. package/dist/cli/commands.js +0 -794
  99. package/dist/cli/commands.js.map +0 -1
  100. package/dist/cli/index.d.ts +0 -2
  101. package/dist/cli/index.d.ts.map +0 -1
  102. package/dist/cli/index.js +0 -19
  103. package/dist/cli/index.js.map +0 -1
  104. package/dist/commands.d.ts +0 -61
  105. package/dist/commands.d.ts.map +0 -1
  106. package/dist/commands.js +0 -861
  107. package/dist/commands.js.map +0 -1
  108. package/dist/config/index.d.ts +0 -71
  109. package/dist/config/index.d.ts.map +0 -1
  110. package/dist/config/index.js +0 -314
  111. package/dist/config/index.js.map +0 -1
  112. package/dist/core/change-detection.d.ts +0 -80
  113. package/dist/core/change-detection.d.ts.map +0 -1
  114. package/dist/core/change-detection.js +0 -523
  115. package/dist/core/change-detection.js.map +0 -1
  116. package/dist/core/config.d.ts +0 -81
  117. package/dist/core/config.d.ts.map +0 -1
  118. package/dist/core/config.js +0 -258
  119. package/dist/core/config.js.map +0 -1
  120. package/dist/core/index.d.ts +0 -6
  121. package/dist/core/index.d.ts.map +0 -1
  122. package/dist/core/index.js +0 -6
  123. package/dist/core/index.js.map +0 -1
  124. package/dist/core/move-detection.d.ts +0 -34
  125. package/dist/core/move-detection.d.ts.map +0 -1
  126. package/dist/core/move-detection.js +0 -121
  127. package/dist/core/move-detection.js.map +0 -1
  128. package/dist/core/snapshot.d.ts +0 -105
  129. package/dist/core/snapshot.d.ts.map +0 -1
  130. package/dist/core/snapshot.js +0 -217
  131. package/dist/core/snapshot.js.map +0 -1
  132. package/dist/core/sync-engine.d.ts +0 -157
  133. package/dist/core/sync-engine.d.ts.map +0 -1
  134. package/dist/core/sync-engine.js +0 -1379
  135. package/dist/core/sync-engine.js.map +0 -1
  136. package/dist/types/config.d.ts +0 -99
  137. package/dist/types/config.d.ts.map +0 -1
  138. package/dist/types/config.js +0 -5
  139. package/dist/types/config.js.map +0 -1
  140. package/dist/types/documents.d.ts +0 -88
  141. package/dist/types/documents.d.ts.map +0 -1
  142. package/dist/types/documents.js +0 -20
  143. package/dist/types/documents.js.map +0 -1
  144. package/dist/types/index.d.ts +0 -4
  145. package/dist/types/index.d.ts.map +0 -1
  146. package/dist/types/index.js +0 -4
  147. package/dist/types/index.js.map +0 -1
  148. package/dist/types/snapshot.d.ts +0 -64
  149. package/dist/types/snapshot.d.ts.map +0 -1
  150. package/dist/types/snapshot.js +0 -2
  151. package/dist/types/snapshot.js.map +0 -1
  152. package/dist/utils/content-similarity.d.ts +0 -53
  153. package/dist/utils/content-similarity.d.ts.map +0 -1
  154. package/dist/utils/content-similarity.js +0 -155
  155. package/dist/utils/content-similarity.js.map +0 -1
  156. package/dist/utils/content.d.ts +0 -10
  157. package/dist/utils/content.d.ts.map +0 -1
  158. package/dist/utils/content.js +0 -31
  159. package/dist/utils/content.js.map +0 -1
  160. package/dist/utils/directory.d.ts +0 -24
  161. package/dist/utils/directory.d.ts.map +0 -1
  162. package/dist/utils/directory.js +0 -52
  163. package/dist/utils/directory.js.map +0 -1
  164. package/dist/utils/fs.d.ts +0 -74
  165. package/dist/utils/fs.d.ts.map +0 -1
  166. package/dist/utils/fs.js +0 -248
  167. package/dist/utils/fs.js.map +0 -1
  168. package/dist/utils/index.d.ts +0 -5
  169. package/dist/utils/index.d.ts.map +0 -1
  170. package/dist/utils/index.js +0 -5
  171. package/dist/utils/index.js.map +0 -1
  172. package/dist/utils/mime-types.d.ts +0 -13
  173. package/dist/utils/mime-types.d.ts.map +0 -1
  174. package/dist/utils/mime-types.js +0 -209
  175. package/dist/utils/mime-types.js.map +0 -1
  176. package/dist/utils/network-sync.d.ts +0 -36
  177. package/dist/utils/network-sync.d.ts.map +0 -1
  178. package/dist/utils/network-sync.js +0 -250
  179. package/dist/utils/network-sync.js.map +0 -1
  180. package/dist/utils/node-polyfills.d.ts +0 -9
  181. package/dist/utils/node-polyfills.d.ts.map +0 -1
  182. package/dist/utils/node-polyfills.js +0 -9
  183. package/dist/utils/node-polyfills.js.map +0 -1
  184. package/dist/utils/output.d.ts +0 -129
  185. package/dist/utils/output.d.ts.map +0 -1
  186. package/dist/utils/output.js +0 -368
  187. package/dist/utils/output.js.map +0 -1
  188. package/dist/utils/repo-factory.d.ts +0 -13
  189. package/dist/utils/repo-factory.d.ts.map +0 -1
  190. package/dist/utils/repo-factory.js +0 -46
  191. package/dist/utils/repo-factory.js.map +0 -1
  192. package/dist/utils/string-similarity.d.ts +0 -14
  193. package/dist/utils/string-similarity.d.ts.map +0 -1
  194. package/dist/utils/string-similarity.js +0 -39
  195. package/dist/utils/string-similarity.js.map +0 -1
  196. package/dist/utils/text-diff.d.ts +0 -37
  197. package/dist/utils/text-diff.d.ts.map +0 -1
  198. package/dist/utils/text-diff.js +0 -93
  199. package/dist/utils/text-diff.js.map +0 -1
  200. package/dist/utils/trace.d.ts +0 -19
  201. package/dist/utils/trace.d.ts.map +0 -1
  202. package/dist/utils/trace.js +0 -63
  203. package/dist/utils/trace.js.map +0 -1
  204. package/src/commands.ts +0 -1134
  205. package/src/core/change-detection.ts +0 -712
  206. package/src/core/config.ts +0 -313
  207. package/src/core/index.ts +0 -5
  208. package/src/core/move-detection.ts +0 -169
  209. package/src/core/snapshot.ts +0 -275
  210. package/src/core/sync-engine.ts +0 -1795
  211. package/src/types/config.ts +0 -111
  212. package/src/types/documents.ts +0 -91
  213. package/src/types/index.ts +0 -3
  214. package/src/types/snapshot.ts +0 -67
  215. package/src/utils/content.ts +0 -34
  216. package/src/utils/directory.ts +0 -73
  217. package/src/utils/fs.ts +0 -297
  218. package/src/utils/index.ts +0 -4
  219. package/src/utils/mime-types.ts +0 -244
  220. package/src/utils/network-sync.ts +0 -319
  221. package/src/utils/node-polyfills.ts +0 -8
  222. package/src/utils/output.ts +0 -450
  223. package/src/utils/repo-factory.ts +0 -73
  224. package/src/utils/string-similarity.ts +0 -54
  225. package/src/utils/text-diff.ts +0 -101
  226. package/src/utils/trace.ts +0 -70
  227. package/test/integration/README.md +0 -328
  228. package/test/integration/clone-test.sh +0 -310
  229. package/test/integration/conflict-resolution-test.sh +0 -309
  230. package/test/integration/debug-both-nested.sh +0 -74
  231. package/test/integration/debug-concurrent-nested.sh +0 -87
  232. package/test/integration/debug-nested.sh +0 -73
  233. package/test/integration/deletion-behavior-test.sh +0 -487
  234. package/test/integration/deletion-sync-test-simple.sh +0 -193
  235. package/test/integration/deletion-sync-test.sh +0 -297
  236. package/test/integration/exclude-patterns.test.ts +0 -144
  237. package/test/integration/full-integration-test.sh +0 -363
  238. package/test/integration/fuzzer.test.ts +0 -818
  239. package/test/integration/in-memory-sync.test.ts +0 -830
  240. package/test/integration/init-sync.test.ts +0 -89
  241. package/test/integration/manual-sync-test.sh +0 -84
  242. package/test/integration/sync-deletion.test.ts +0 -280
  243. package/test/integration/sync-flow.test.ts +0 -291
  244. package/test/jest.setup.ts +0 -34
  245. package/test/run-tests.sh +0 -225
  246. package/test/unit/deletion-behavior.test.ts +0 -249
  247. package/test/unit/enhanced-mime-detection.test.ts +0 -244
  248. package/test/unit/snapshot.test.ts +0 -404
  249. package/test/unit/sync-convergence.test.ts +0 -298
  250. package/test/unit/sync-timing.test.ts +0 -134
  251. package/test/unit/utils.test.ts +0 -366
@@ -1,830 +0,0 @@
1
- /**
2
- * Sync Reliability Tests
3
- *
4
- * These tests verify sync reliability using the CLI subprocess pattern
5
- * (same as fuzzer.test.ts) but with convergence-based assertions.
6
- *
7
- * Key difference from fuzzer tests: instead of fixed delays, we use
8
- * convergence detection to know when sync is complete.
9
- */
10
-
11
- import * as fs from "fs/promises";
12
- import * as path from "path";
13
- import * as tmp from "tmp";
14
- import { execFile } from "child_process";
15
- import { promisify } from "util";
16
- import * as crypto from "crypto";
17
-
18
- const execFilePromise = promisify(execFile);
19
-
20
- // Path to the pushwork CLI
21
- const PUSHWORK_CLI = path.join(__dirname, "../../dist/cli.js");
22
-
23
- /**
24
- * Execute pushwork CLI command
25
- */
26
- async function pushwork(
27
- args: string[],
28
- cwd: string
29
- ): Promise<{ stdout: string; stderr: string }> {
30
- try {
31
- const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], {
32
- cwd,
33
- env: { ...process.env, FORCE_COLOR: "0" },
34
- });
35
- return result;
36
- } catch (error: any) {
37
- throw new Error(
38
- `pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${error.stdout}\nstderr: ${error.stderr}`
39
- );
40
- }
41
- }
42
-
43
- /**
44
- * Compute hash of all files in a directory (excluding .pushwork)
45
- */
46
- async function hashDirectory(dirPath: string): Promise<string> {
47
- const files = await getAllFiles(dirPath);
48
- const hash = crypto.createHash("sha256");
49
-
50
- files.sort();
51
-
52
- for (const file of files) {
53
- if (file.includes(".pushwork")) continue;
54
-
55
- const fullPath = path.join(dirPath, file);
56
- const content = await fs.readFile(fullPath);
57
-
58
- hash.update(file);
59
- hash.update(content);
60
- }
61
-
62
- return hash.digest("hex");
63
- }
64
-
65
- /**
66
- * Recursively get all files in a directory
67
- */
68
- async function getAllFiles(
69
- dirPath: string,
70
- basePath: string = dirPath
71
- ): Promise<string[]> {
72
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
73
- const files: string[] = [];
74
-
75
- for (const entry of entries) {
76
- const fullPath = path.join(dirPath, entry.name);
77
- const relativePath = path.relative(basePath, fullPath);
78
-
79
- if (entry.isDirectory()) {
80
- if (entry.name === ".pushwork") continue;
81
- const subFiles = await getAllFiles(fullPath, basePath);
82
- files.push(...subFiles);
83
- } else if (entry.isFile()) {
84
- files.push(relativePath);
85
- }
86
- }
87
-
88
- return files;
89
- }
90
-
91
- /**
92
- * Check if a path exists
93
- */
94
- async function pathExists(filePath: string): Promise<boolean> {
95
- try {
96
- await fs.access(filePath);
97
- return true;
98
- } catch {
99
- return false;
100
- }
101
- }
102
-
103
- /**
104
- * Sync until repos converge or max rounds reached.
105
- * Returns the number of rounds it took to converge, or throws if it didn't.
106
- *
107
- * This is the key helper - instead of fixed delays, we sync until convergence.
108
- */
109
- async function syncUntilConverged(
110
- repoA: string,
111
- repoB: string,
112
- options: {
113
- maxRounds?: number;
114
- timeoutMs?: number;
115
- } = {}
116
- ): Promise<{ rounds: number; hashA: string; hashB: string }> {
117
- const { maxRounds = 5, timeoutMs = 30000 } = options;
118
- const startTime = Date.now();
119
-
120
- for (let round = 1; round <= maxRounds; round++) {
121
- if (Date.now() - startTime > timeoutMs) {
122
- const hashA = await hashDirectory(repoA);
123
- const hashB = await hashDirectory(repoB);
124
- throw new Error(
125
- `Sync timeout after ${round - 1} rounds and ${Date.now() - startTime}ms. ` +
126
- `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}`
127
- );
128
- }
129
-
130
- // Sync both repos (use --gentle for incremental sync)
131
- await pushwork(["sync", "--gentle"], repoA);
132
- await pushwork(["sync", "--gentle"], repoB);
133
-
134
- // Check if converged
135
- const hashA = await hashDirectory(repoA);
136
- const hashB = await hashDirectory(repoB);
137
-
138
- if (hashA === hashB) {
139
- return { rounds: round, hashA, hashB };
140
- }
141
- }
142
-
143
- const hashA = await hashDirectory(repoA);
144
- const hashB = await hashDirectory(repoB);
145
- throw new Error(
146
- `Failed to converge after ${maxRounds} sync rounds. ` +
147
- `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}`
148
- );
149
- }
150
-
151
- describe("Sync Reliability Tests", () => {
152
- let tmpDir: string;
153
- let cleanup: () => void;
154
-
155
- beforeEach(() => {
156
- const tmpObj = tmp.dirSync({ unsafeCleanup: true });
157
- tmpDir = tmpObj.name;
158
- cleanup = tmpObj.removeCallback;
159
- });
160
-
161
- afterEach(() => {
162
- cleanup();
163
- });
164
-
165
- describe("Basic Two-Repo Sync", () => {
166
- /**
167
- * STRICT TEST: Check state immediately after clone, no extra syncs.
168
- * This should expose the same issues as the fuzzer.
169
- */
170
- it("should have matching state immediately after clone (strict)", async () => {
171
- const repoA = path.join(tmpDir, "repo-a");
172
- const repoB = path.join(tmpDir, "repo-b");
173
- await fs.mkdir(repoA);
174
- await fs.mkdir(repoB);
175
-
176
- // Create file and init A
177
- await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A");
178
- await pushwork(["init", "."], repoA);
179
-
180
- // Clone to B (no extra syncs!)
181
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
182
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
183
-
184
- // STRICT: Check immediately, no syncUntilConverged
185
- const hashA = await hashDirectory(repoA);
186
- const hashB = await hashDirectory(repoB);
187
-
188
- // Debug output if they don't match
189
- if (hashA !== hashB) {
190
- const filesA = await getAllFiles(repoA);
191
- const filesB = await getAllFiles(repoB);
192
- console.log("MISMATCH DETECTED:");
193
- console.log(" repoA files:", filesA.filter(f => !f.includes(".pushwork")));
194
- console.log(" repoB files:", filesB.filter(f => !f.includes(".pushwork")));
195
- console.log(" hashA:", hashA.slice(0, 16));
196
- console.log(" hashB:", hashB.slice(0, 16));
197
- }
198
-
199
- expect(hashA).toBe(hashB);
200
-
201
- // Verify file exists in both
202
- expect(await pathExists(path.join(repoA, "test.txt"))).toBe(true);
203
- expect(await pathExists(path.join(repoB, "test.txt"))).toBe(true);
204
-
205
- // Verify content matches
206
- const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8");
207
- const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8");
208
- expect(contentA).toBe("Hello from A");
209
- expect(contentB).toBe("Hello from A");
210
- }, 30000);
211
-
212
- it("should sync a file from A to B (with convergence)", async () => {
213
- const repoA = path.join(tmpDir, "repo-a");
214
- const repoB = path.join(tmpDir, "repo-b");
215
- await fs.mkdir(repoA);
216
- await fs.mkdir(repoB);
217
-
218
- // Create file and init A
219
- await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A");
220
- await pushwork(["init", "."], repoA);
221
-
222
- // Clone to B
223
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
224
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
225
-
226
- // Verify convergence (allows retries)
227
- const { rounds, hashA, hashB } = await syncUntilConverged(repoA, repoB);
228
-
229
- expect(hashA).toBe(hashB);
230
- expect(rounds).toBeLessThanOrEqual(2); // Should converge quickly
231
-
232
- // Verify content
233
- const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8");
234
- const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8");
235
- expect(contentA).toBe(contentB);
236
- expect(contentA).toBe("Hello from A");
237
- }, 30000);
238
-
239
- it("should sync a new file added to B back to A", async () => {
240
- const repoA = path.join(tmpDir, "repo-a");
241
- const repoB = path.join(tmpDir, "repo-b");
242
- await fs.mkdir(repoA);
243
- await fs.mkdir(repoB);
244
-
245
- // Init A with initial file
246
- await fs.writeFile(path.join(repoA, "initial.txt"), "initial");
247
- await pushwork(["init", "."], repoA);
248
-
249
- // Clone to B
250
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
251
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
252
-
253
- // Initial convergence
254
- await syncUntilConverged(repoA, repoB);
255
-
256
- // B creates new file
257
- await fs.writeFile(path.join(repoB, "from-b.txt"), "Created by B");
258
-
259
- // Sync until converged
260
- const { rounds } = await syncUntilConverged(repoA, repoB);
261
-
262
- expect(rounds).toBeLessThanOrEqual(3);
263
-
264
- // Verify A got B's file
265
- expect(await pathExists(path.join(repoA, "from-b.txt"))).toBe(true);
266
- const content = await fs.readFile(path.join(repoA, "from-b.txt"), "utf-8");
267
- expect(content).toBe("Created by B");
268
- }, 30000);
269
-
270
- it("should sync subdirectories correctly", async () => {
271
- const repoA = path.join(tmpDir, "repo-a");
272
- const repoB = path.join(tmpDir, "repo-b");
273
- await fs.mkdir(repoA);
274
- await fs.mkdir(repoB);
275
-
276
- // Create nested structure in A
277
- await fs.mkdir(path.join(repoA, "subdir"), { recursive: true });
278
- await fs.writeFile(path.join(repoA, "subdir", "nested.txt"), "Nested content");
279
- await pushwork(["init", "."], repoA);
280
-
281
- // Clone to B
282
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
283
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
284
-
285
- // Verify convergence
286
- const { rounds } = await syncUntilConverged(repoA, repoB);
287
-
288
- expect(rounds).toBeLessThanOrEqual(2);
289
-
290
- // Verify B got the nested file
291
- expect(await pathExists(path.join(repoB, "subdir", "nested.txt"))).toBe(true);
292
- const content = await fs.readFile(path.join(repoB, "subdir", "nested.txt"), "utf-8");
293
- expect(content).toBe("Nested content");
294
- }, 30000);
295
- });
296
-
297
- describe("Concurrent Operations", () => {
298
- it("should handle concurrent file creation on both sides", async () => {
299
- const repoA = path.join(tmpDir, "repo-a");
300
- const repoB = path.join(tmpDir, "repo-b");
301
- await fs.mkdir(repoA);
302
- await fs.mkdir(repoB);
303
-
304
- // Init A
305
- await fs.writeFile(path.join(repoA, "initial.txt"), "initial");
306
- await pushwork(["init", "."], repoA);
307
-
308
- // Clone to B
309
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
310
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
311
-
312
- // Initial convergence
313
- await syncUntilConverged(repoA, repoB);
314
-
315
- // Both create files concurrently (before syncing)
316
- await fs.writeFile(path.join(repoA, "file-a.txt"), "From A");
317
- await fs.writeFile(path.join(repoB, "file-b.txt"), "From B");
318
-
319
- // Sync until converged
320
- const { rounds } = await syncUntilConverged(repoA, repoB);
321
-
322
- expect(rounds).toBeLessThanOrEqual(3);
323
-
324
- // Both should have both files
325
- expect(await pathExists(path.join(repoA, "file-a.txt"))).toBe(true);
326
- expect(await pathExists(path.join(repoA, "file-b.txt"))).toBe(true);
327
- expect(await pathExists(path.join(repoB, "file-a.txt"))).toBe(true);
328
- expect(await pathExists(path.join(repoB, "file-b.txt"))).toBe(true);
329
- }, 30000);
330
-
331
- it("should handle file modification sync", async () => {
332
- const repoA = path.join(tmpDir, "repo-a");
333
- const repoB = path.join(tmpDir, "repo-b");
334
- await fs.mkdir(repoA);
335
- await fs.mkdir(repoB);
336
-
337
- // Init A with file
338
- await fs.writeFile(path.join(repoA, "shared.txt"), "Original");
339
- await pushwork(["init", "."], repoA);
340
-
341
- // Clone to B
342
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
343
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
344
-
345
- // Initial convergence
346
- await syncUntilConverged(repoA, repoB);
347
-
348
- // A modifies the file
349
- await fs.writeFile(path.join(repoA, "shared.txt"), "Modified by A");
350
-
351
- // Sync until converged
352
- const { rounds } = await syncUntilConverged(repoA, repoB);
353
-
354
- expect(rounds).toBeLessThanOrEqual(3);
355
-
356
- // B should have the modification
357
- const contentB = await fs.readFile(path.join(repoB, "shared.txt"), "utf-8");
358
- expect(contentB).toBe("Modified by A");
359
- }, 30000);
360
-
361
- it("should handle file deletion sync", async () => {
362
- const repoA = path.join(tmpDir, "repo-a");
363
- const repoB = path.join(tmpDir, "repo-b");
364
- await fs.mkdir(repoA);
365
- await fs.mkdir(repoB);
366
-
367
- // Init A with file
368
- await fs.writeFile(path.join(repoA, "to-delete.txt"), "Will be deleted");
369
- await pushwork(["init", "."], repoA);
370
-
371
- // Clone to B
372
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
373
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
374
-
375
- // Initial convergence
376
- await syncUntilConverged(repoA, repoB);
377
-
378
- // Verify B has the file
379
- expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(true);
380
-
381
- // A deletes the file
382
- await fs.unlink(path.join(repoA, "to-delete.txt"));
383
-
384
- // Sync until converged
385
- const { rounds } = await syncUntilConverged(repoA, repoB);
386
-
387
- expect(rounds).toBeLessThanOrEqual(3);
388
-
389
- // File should be deleted in B
390
- expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(false);
391
- }, 30000);
392
- });
393
-
394
- describe("Subdirectory File Deletion - Resurrection Bug", () => {
395
- it("deleted file in artifact directory should not resurrect", async () => {
396
- // Files in artifact directories (dist/ by default) resurrect after sync.
397
- // Phase 1 (push) correctly removes the file entry from the directory doc,
398
- // but the Automerge merge with the server's version re-introduces it.
399
- // Phase 2 (pull) then sees it as a "new remote document" and re-creates it.
400
- const repoA = path.join(tmpDir, "repo-a");
401
- await fs.mkdir(repoA);
402
-
403
- await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true });
404
- await fs.writeFile(path.join(repoA, "dist", "assets", "app.js"), "// build 1");
405
- await pushwork(["init", "."], repoA);
406
- await pushwork(["sync"], repoA);
407
-
408
- // Delete the file
409
- await fs.unlink(path.join(repoA, "dist", "assets", "app.js"));
410
-
411
- // Sync - push deletion then pull
412
- await pushwork(["sync"], repoA);
413
-
414
- // File should stay deleted
415
- expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false);
416
-
417
- // Sync again - should NOT come back from server
418
- await pushwork(["sync"], repoA);
419
- expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false);
420
- }, 60000);
421
-
422
- it("deleted file in depth-1 subdirectory should not resurrect (control)", async () => {
423
- // Control: depth-1 subdirectories work correctly
424
- const repoA = path.join(tmpDir, "repo-a");
425
- await fs.mkdir(repoA);
426
-
427
- await fs.mkdir(path.join(repoA, "subdir"), { recursive: true });
428
- await fs.writeFile(path.join(repoA, "subdir", "file.txt"), "content");
429
- await pushwork(["init", "."], repoA);
430
- await pushwork(["sync"], repoA);
431
-
432
- await fs.unlink(path.join(repoA, "subdir", "file.txt"));
433
-
434
- await pushwork(["sync"], repoA);
435
- expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false);
436
-
437
- await pushwork(["sync"], repoA);
438
- expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false);
439
- }, 60000);
440
-
441
- it("deleted build artifacts should not resurrect after rebuild cycle", async () => {
442
- // Real-world scenario: build step creates new hashed files and deletes
443
- // old ones in dist/assets/. The deleted files come back from the server.
444
- const repoA = path.join(tmpDir, "repo-a");
445
- await fs.mkdir(repoA);
446
-
447
- await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true });
448
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1");
449
- await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1");
450
- await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1");
451
- await pushwork(["init", "."], repoA);
452
- await pushwork(["sync"], repoA);
453
-
454
- // Simulate rebuild: new hashed files replace old ones
455
- await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js"));
456
- await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js"));
457
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2");
458
- await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2");
459
- await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2");
460
-
461
- await pushwork(["sync"], repoA);
462
-
463
- // Old files should be gone, new files should exist
464
- expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false);
465
- expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false);
466
- expect(await pathExists(path.join(repoA, "dist", "assets", "app-XYZ789.js"))).toBe(true);
467
- expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-UVW012.js"))).toBe(true);
468
-
469
- // Sync again - old files should NOT come back from server
470
- await pushwork(["sync"], repoA);
471
-
472
- expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false);
473
- expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false);
474
- }, 60000);
475
-
476
- it("deleted artifact files should not resurrect on clone", async () => {
477
- // Two repos: A deletes files in an artifact directory, B should not
478
- // see the deleted files after syncing.
479
- const repoA = path.join(tmpDir, "repo-a");
480
- const repoB = path.join(tmpDir, "repo-b");
481
- await fs.mkdir(repoA);
482
- await fs.mkdir(repoB);
483
-
484
- await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true });
485
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1");
486
- await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1");
487
- await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1");
488
- await pushwork(["init", "."], repoA);
489
-
490
- // Clone to B and converge
491
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
492
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
493
- await syncUntilConverged(repoA, repoB);
494
-
495
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(true);
496
-
497
- // A rebuilds
498
- await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js"));
499
- await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js"));
500
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2");
501
- await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2");
502
- await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2");
503
-
504
- // Sync A then B
505
- await pushwork(["sync"], repoA);
506
-
507
- // A should not have resurrected files
508
- expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false);
509
- expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false);
510
-
511
- await pushwork(["sync"], repoB);
512
-
513
- // B should have new files, NOT old files
514
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(false);
515
- expect(await pathExists(path.join(repoB, "dist", "assets", "vendor-DEF456.js"))).toBe(false);
516
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ789.js"))).toBe(true);
517
- }, 90000);
518
-
519
- it("deleted file in depth-3 subdirectory should not resurrect", async () => {
520
- const repoA = path.join(tmpDir, "repo-a");
521
- await fs.mkdir(repoA);
522
-
523
- await fs.mkdir(path.join(repoA, "a", "b", "c"), { recursive: true });
524
- await fs.writeFile(path.join(repoA, "a", "b", "c", "deep.txt"), "deep");
525
- await pushwork(["init", "."], repoA);
526
- await pushwork(["sync"], repoA);
527
-
528
- await fs.unlink(path.join(repoA, "a", "b", "c", "deep.txt"));
529
-
530
- await pushwork(["sync"], repoA);
531
- expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false);
532
-
533
- await pushwork(["sync"], repoA);
534
- expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false);
535
- }, 60000);
536
-
537
- it("create+delete in same subdirectory should not resurrect deleted files", async () => {
538
- // Regression guard: simultaneous create+delete in the same non-artifact
539
- // subdirectory should work. This passes today but we don't want it to regress.
540
- const repoA = path.join(tmpDir, "repo-a");
541
- await fs.mkdir(repoA);
542
-
543
- await fs.mkdir(path.join(repoA, "subdir"), { recursive: true });
544
- await fs.writeFile(path.join(repoA, "subdir", "old.txt"), "old content");
545
- await pushwork(["init", "."], repoA);
546
- await pushwork(["sync"], repoA);
547
-
548
- // Simultaneously create new file and delete old file in same dir
549
- await fs.unlink(path.join(repoA, "subdir", "old.txt"));
550
- await fs.writeFile(path.join(repoA, "subdir", "new.txt"), "new content");
551
-
552
- await pushwork(["sync"], repoA);
553
-
554
- expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false);
555
- expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true);
556
-
557
- // Sync again - old file should NOT come back
558
- await pushwork(["sync"], repoA);
559
-
560
- expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false);
561
- expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true);
562
- }, 60000);
563
-
564
- it("deleted file in depth-2 with sibling dirs should not resurrect", async () => {
565
- // The depth-3 test has intermediate dirs (a/b/c) with only one child each.
566
- // The dist/assets test has dist/ containing both assets/ (subdir) and
567
- // index.js (file). Test if having a file sibling alongside the subdir matters.
568
- const repoA = path.join(tmpDir, "repo-a");
569
- await fs.mkdir(repoA);
570
-
571
- await fs.mkdir(path.join(repoA, "parent", "child"), { recursive: true });
572
- await fs.writeFile(path.join(repoA, "parent", "sibling.txt"), "sibling at parent level");
573
- await fs.writeFile(path.join(repoA, "parent", "child", "target.txt"), "will be deleted");
574
- await pushwork(["init", "."], repoA);
575
- await pushwork(["sync"], repoA);
576
-
577
- await fs.unlink(path.join(repoA, "parent", "child", "target.txt"));
578
-
579
- await pushwork(["sync"], repoA);
580
- expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false);
581
- expect(await pathExists(path.join(repoA, "parent", "sibling.txt"))).toBe(true);
582
-
583
- await pushwork(["sync"], repoA);
584
- expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false);
585
- }, 60000);
586
-
587
- it("deleted file in root directory should not resurrect", async () => {
588
- const repoA = path.join(tmpDir, "repo-a");
589
- await fs.mkdir(repoA);
590
-
591
- await fs.writeFile(path.join(repoA, "root-file.txt"), "root content");
592
- await fs.writeFile(path.join(repoA, "keep.txt"), "keep this");
593
- await pushwork(["init", "."], repoA);
594
- await pushwork(["sync"], repoA);
595
-
596
- // Delete file in root
597
- await fs.unlink(path.join(repoA, "root-file.txt"));
598
-
599
- await pushwork(["sync"], repoA);
600
- expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false);
601
- expect(await pathExists(path.join(repoA, "keep.txt"))).toBe(true);
602
-
603
- // Sync again - should NOT come back
604
- await pushwork(["sync"], repoA);
605
- expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false);
606
- }, 60000);
607
-
608
- it("deleted file in non-artifact subdirectory (src/) should not resurrect", async () => {
609
- const repoA = path.join(tmpDir, "repo-a");
610
- await fs.mkdir(repoA);
611
-
612
- await fs.mkdir(path.join(repoA, "src"), { recursive: true });
613
- await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1");
614
- await fs.writeFile(path.join(repoA, "src", "helper.ts"), "export function help() {}");
615
- await pushwork(["init", "."], repoA);
616
- await pushwork(["sync"], repoA);
617
-
618
- // Delete one file in src/
619
- await fs.unlink(path.join(repoA, "src", "helper.ts"));
620
-
621
- await pushwork(["sync"], repoA);
622
- expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false);
623
- expect(await pathExists(path.join(repoA, "src", "index.ts"))).toBe(true);
624
-
625
- // Sync again - should NOT come back
626
- await pushwork(["sync"], repoA);
627
- expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false);
628
- }, 60000);
629
-
630
- it("deleted files should not resurrect after multiple sync cycles", async () => {
631
- // Simulate real-world usage: multiple syncs over time with deletions
632
- const repoA = path.join(tmpDir, "repo-a");
633
- await fs.mkdir(repoA);
634
-
635
- await fs.mkdir(path.join(repoA, "src"), { recursive: true });
636
- await fs.writeFile(path.join(repoA, "readme.txt"), "readme");
637
- await fs.writeFile(path.join(repoA, "src", "app.ts"), "app");
638
- await fs.writeFile(path.join(repoA, "src", "old.ts"), "old");
639
- await pushwork(["init", "."], repoA);
640
- await pushwork(["sync"], repoA);
641
-
642
- // Cycle 1: delete root file
643
- await fs.unlink(path.join(repoA, "readme.txt"));
644
- await pushwork(["sync"], repoA);
645
- expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false);
646
-
647
- // Cycle 2: delete src file
648
- await fs.unlink(path.join(repoA, "src", "old.ts"));
649
- await pushwork(["sync"], repoA);
650
- expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false);
651
-
652
- // Cycle 3: just sync - nothing should come back
653
- await pushwork(["sync"], repoA);
654
- expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false);
655
- expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false);
656
- expect(await pathExists(path.join(repoA, "src", "app.ts"))).toBe(true);
657
- }, 90000);
658
-
659
- it("peer B should not see files deleted by peer A (root)", async () => {
660
- const repoA = path.join(tmpDir, "repo-a");
661
- const repoB = path.join(tmpDir, "repo-b");
662
- await fs.mkdir(repoA);
663
- await fs.mkdir(repoB);
664
-
665
- await fs.writeFile(path.join(repoA, "keep.txt"), "keep");
666
- await fs.writeFile(path.join(repoA, "delete-me.txt"), "gone");
667
- await pushwork(["init", "."], repoA);
668
-
669
- // Clone to B and converge
670
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
671
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
672
- await syncUntilConverged(repoA, repoB);
673
-
674
- expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(true);
675
-
676
- // A deletes a root file
677
- await fs.unlink(path.join(repoA, "delete-me.txt"));
678
- await pushwork(["sync"], repoA);
679
-
680
- // B syncs - should see the deletion
681
- await pushwork(["sync"], repoB);
682
- expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false);
683
- expect(await pathExists(path.join(repoB, "keep.txt"))).toBe(true);
684
-
685
- // B syncs again - should stay deleted
686
- await pushwork(["sync"], repoB);
687
- expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false);
688
- }, 90000);
689
-
690
- it("peer B should not see files deleted by peer A (src/)", async () => {
691
- const repoA = path.join(tmpDir, "repo-a");
692
- const repoB = path.join(tmpDir, "repo-b");
693
- await fs.mkdir(repoA);
694
- await fs.mkdir(repoB);
695
-
696
- await fs.mkdir(path.join(repoA, "src"), { recursive: true });
697
- await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1");
698
- await fs.writeFile(path.join(repoA, "src", "old.ts"), "old code");
699
- await pushwork(["init", "."], repoA);
700
-
701
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
702
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
703
- await syncUntilConverged(repoA, repoB);
704
-
705
- expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(true);
706
-
707
- // A deletes a file in src/
708
- await fs.unlink(path.join(repoA, "src", "old.ts"));
709
- await pushwork(["sync"], repoA);
710
-
711
- // B syncs - should see the deletion
712
- await pushwork(["sync"], repoB);
713
- expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false);
714
- expect(await pathExists(path.join(repoB, "src", "index.ts"))).toBe(true);
715
-
716
- // B syncs again - should stay deleted
717
- await pushwork(["sync"], repoB);
718
- expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false);
719
- }, 90000);
720
-
721
- it("peer B should not see files deleted by peer A (dist/)", async () => {
722
- const repoA = path.join(tmpDir, "repo-a");
723
- const repoB = path.join(tmpDir, "repo-b");
724
- await fs.mkdir(repoA);
725
- await fs.mkdir(repoB);
726
-
727
- await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true });
728
- await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index");
729
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC.js"), "// build 1");
730
- await pushwork(["init", "."], repoA);
731
-
732
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
733
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
734
- await syncUntilConverged(repoA, repoB);
735
-
736
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(true);
737
-
738
- // A rebuilds: delete old artifact, create new one
739
- await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC.js"));
740
- await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ.js"), "// build 2");
741
- await pushwork(["sync"], repoA);
742
-
743
- // A should not have resurrected
744
- expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC.js"))).toBe(false);
745
-
746
- // B syncs - should see new file, NOT old file
747
- await pushwork(["sync"], repoB);
748
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false);
749
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ.js"))).toBe(true);
750
-
751
- // B syncs again - old file should stay gone
752
- await pushwork(["sync"], repoB);
753
- expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false);
754
- }, 90000);
755
-
756
- it("peer B should see artifact file content update after URL replacement", async () => {
757
- // When peer A modifies an artifact file, the document is replaced entirely
758
- // (new Automerge doc with a new URL). Peer B's snapshot still points to the
759
- // old (now orphaned) URL. detectRemoteChanges sees no head change on the old
760
- // doc, and detectNewRemoteDocuments skips paths already in the snapshot.
761
- // Without URL replacement detection, B never sees the update.
762
- const repoA = path.join(tmpDir, "repo-a");
763
- const repoB = path.join(tmpDir, "repo-b");
764
- await fs.mkdir(repoA);
765
- await fs.mkdir(repoB);
766
-
767
- await fs.mkdir(path.join(repoA, "dist"), { recursive: true });
768
- await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 1");
769
- await pushwork(["init", "."], repoA);
770
-
771
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
772
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
773
- await syncUntilConverged(repoA, repoB);
774
-
775
- const bContentV1 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8");
776
- expect(bContentV1).toBe("// version 1");
777
-
778
- // A modifies the artifact file — this triggers nuclear replacement (new URL)
779
- await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 2");
780
- await pushwork(["sync"], repoA);
781
-
782
- // B syncs — should pick up the new content despite the URL change
783
- await pushwork(["sync"], repoB);
784
- const bContentV2 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8");
785
- expect(bContentV2).toBe("// version 2");
786
- }, 90000);
787
- });
788
-
789
- describe("Move/Rename Detection", () => {
790
- it("should handle file rename", async () => {
791
- const repoA = path.join(tmpDir, "repo-a");
792
- const repoB = path.join(tmpDir, "repo-b");
793
- await fs.mkdir(repoA);
794
- await fs.mkdir(repoB);
795
-
796
- // Init A with file
797
- const content = "This content will be used for similarity detection during move";
798
- await fs.writeFile(path.join(repoA, "original.txt"), content);
799
- await pushwork(["init", "."], repoA);
800
-
801
- // Clone to B
802
- const { stdout: rootUrl } = await pushwork(["url"], repoA);
803
- await pushwork(["clone", rootUrl.trim(), repoB], tmpDir);
804
-
805
- // Initial convergence
806
- await syncUntilConverged(repoA, repoB);
807
-
808
- // A renames the file
809
- await fs.rename(
810
- path.join(repoA, "original.txt"),
811
- path.join(repoA, "renamed.txt")
812
- );
813
-
814
- // Sync until converged
815
- const { rounds } = await syncUntilConverged(repoA, repoB);
816
-
817
- expect(rounds).toBeLessThanOrEqual(3);
818
-
819
- // Verify both repos have renamed.txt and not original.txt
820
- expect(await pathExists(path.join(repoA, "original.txt"))).toBe(false);
821
- expect(await pathExists(path.join(repoA, "renamed.txt"))).toBe(true);
822
- expect(await pathExists(path.join(repoB, "original.txt"))).toBe(false);
823
- expect(await pathExists(path.join(repoB, "renamed.txt"))).toBe(true);
824
-
825
- // Verify content preserved
826
- const contentB = await fs.readFile(path.join(repoB, "renamed.txt"), "utf-8");
827
- expect(contentB).toBe(content);
828
- }, 30000);
829
- });
830
- });