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

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 (234) hide show
  1. package/dist/branches.d.ts +20 -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 +245 -270
  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 +35 -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 +129 -0
  30. package/dist/pushwork.d.ts.map +1 -0
  31. package/dist/pushwork.js +1062 -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 +38 -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 +92 -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/dist/version.d.ts +11 -0
  66. package/dist/version.d.ts.map +1 -0
  67. package/dist/version.js +93 -0
  68. package/dist/version.js.map +1 -0
  69. package/package.json +19 -48
  70. package/patches/@automerge__automerge-repo@2.6.0-subduction.15.patch +26 -0
  71. package/.prettierrc +0 -9
  72. package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +0 -248
  73. package/CLAUDE.md +0 -141
  74. package/README.md +0 -221
  75. package/babel.config.js +0 -5
  76. package/dist/cli/commands.d.ts +0 -71
  77. package/dist/cli/commands.d.ts.map +0 -1
  78. package/dist/cli/commands.js +0 -794
  79. package/dist/cli/commands.js.map +0 -1
  80. package/dist/cli/index.d.ts +0 -2
  81. package/dist/cli/index.d.ts.map +0 -1
  82. package/dist/cli/index.js +0 -19
  83. package/dist/cli/index.js.map +0 -1
  84. package/dist/commands.d.ts +0 -61
  85. package/dist/commands.d.ts.map +0 -1
  86. package/dist/commands.js +0 -861
  87. package/dist/commands.js.map +0 -1
  88. package/dist/config/index.d.ts +0 -71
  89. package/dist/config/index.d.ts.map +0 -1
  90. package/dist/config/index.js +0 -314
  91. package/dist/config/index.js.map +0 -1
  92. package/dist/core/change-detection.d.ts +0 -80
  93. package/dist/core/change-detection.d.ts.map +0 -1
  94. package/dist/core/change-detection.js +0 -523
  95. package/dist/core/change-detection.js.map +0 -1
  96. package/dist/core/config.d.ts +0 -81
  97. package/dist/core/config.d.ts.map +0 -1
  98. package/dist/core/config.js +0 -258
  99. package/dist/core/config.js.map +0 -1
  100. package/dist/core/index.d.ts +0 -6
  101. package/dist/core/index.d.ts.map +0 -1
  102. package/dist/core/index.js +0 -6
  103. package/dist/core/index.js.map +0 -1
  104. package/dist/core/move-detection.d.ts +0 -34
  105. package/dist/core/move-detection.d.ts.map +0 -1
  106. package/dist/core/move-detection.js +0 -121
  107. package/dist/core/move-detection.js.map +0 -1
  108. package/dist/core/snapshot.d.ts +0 -105
  109. package/dist/core/snapshot.d.ts.map +0 -1
  110. package/dist/core/snapshot.js +0 -217
  111. package/dist/core/snapshot.js.map +0 -1
  112. package/dist/core/sync-engine.d.ts +0 -157
  113. package/dist/core/sync-engine.d.ts.map +0 -1
  114. package/dist/core/sync-engine.js +0 -1379
  115. package/dist/core/sync-engine.js.map +0 -1
  116. package/dist/types/config.d.ts +0 -99
  117. package/dist/types/config.d.ts.map +0 -1
  118. package/dist/types/config.js +0 -5
  119. package/dist/types/config.js.map +0 -1
  120. package/dist/types/documents.d.ts +0 -88
  121. package/dist/types/documents.d.ts.map +0 -1
  122. package/dist/types/documents.js +0 -20
  123. package/dist/types/documents.js.map +0 -1
  124. package/dist/types/index.d.ts +0 -4
  125. package/dist/types/index.d.ts.map +0 -1
  126. package/dist/types/index.js +0 -4
  127. package/dist/types/index.js.map +0 -1
  128. package/dist/types/snapshot.d.ts +0 -64
  129. package/dist/types/snapshot.d.ts.map +0 -1
  130. package/dist/types/snapshot.js +0 -2
  131. package/dist/types/snapshot.js.map +0 -1
  132. package/dist/utils/content-similarity.d.ts +0 -53
  133. package/dist/utils/content-similarity.d.ts.map +0 -1
  134. package/dist/utils/content-similarity.js +0 -155
  135. package/dist/utils/content-similarity.js.map +0 -1
  136. package/dist/utils/content.d.ts +0 -10
  137. package/dist/utils/content.d.ts.map +0 -1
  138. package/dist/utils/content.js +0 -31
  139. package/dist/utils/content.js.map +0 -1
  140. package/dist/utils/directory.d.ts +0 -24
  141. package/dist/utils/directory.d.ts.map +0 -1
  142. package/dist/utils/directory.js +0 -52
  143. package/dist/utils/directory.js.map +0 -1
  144. package/dist/utils/fs.d.ts +0 -74
  145. package/dist/utils/fs.d.ts.map +0 -1
  146. package/dist/utils/fs.js +0 -248
  147. package/dist/utils/fs.js.map +0 -1
  148. package/dist/utils/index.d.ts +0 -5
  149. package/dist/utils/index.d.ts.map +0 -1
  150. package/dist/utils/index.js +0 -5
  151. package/dist/utils/index.js.map +0 -1
  152. package/dist/utils/mime-types.d.ts +0 -13
  153. package/dist/utils/mime-types.d.ts.map +0 -1
  154. package/dist/utils/mime-types.js +0 -209
  155. package/dist/utils/mime-types.js.map +0 -1
  156. package/dist/utils/network-sync.d.ts +0 -36
  157. package/dist/utils/network-sync.d.ts.map +0 -1
  158. package/dist/utils/network-sync.js +0 -250
  159. package/dist/utils/network-sync.js.map +0 -1
  160. package/dist/utils/node-polyfills.d.ts +0 -9
  161. package/dist/utils/node-polyfills.d.ts.map +0 -1
  162. package/dist/utils/node-polyfills.js +0 -9
  163. package/dist/utils/node-polyfills.js.map +0 -1
  164. package/dist/utils/output.d.ts +0 -129
  165. package/dist/utils/output.d.ts.map +0 -1
  166. package/dist/utils/output.js +0 -368
  167. package/dist/utils/output.js.map +0 -1
  168. package/dist/utils/repo-factory.d.ts +0 -13
  169. package/dist/utils/repo-factory.d.ts.map +0 -1
  170. package/dist/utils/repo-factory.js +0 -46
  171. package/dist/utils/repo-factory.js.map +0 -1
  172. package/dist/utils/string-similarity.d.ts +0 -14
  173. package/dist/utils/string-similarity.d.ts.map +0 -1
  174. package/dist/utils/string-similarity.js +0 -39
  175. package/dist/utils/string-similarity.js.map +0 -1
  176. package/dist/utils/text-diff.d.ts +0 -37
  177. package/dist/utils/text-diff.d.ts.map +0 -1
  178. package/dist/utils/text-diff.js +0 -93
  179. package/dist/utils/text-diff.js.map +0 -1
  180. package/dist/utils/trace.d.ts +0 -19
  181. package/dist/utils/trace.d.ts.map +0 -1
  182. package/dist/utils/trace.js +0 -63
  183. package/dist/utils/trace.js.map +0 -1
  184. package/src/cli.ts +0 -442
  185. package/src/commands.ts +0 -1134
  186. package/src/core/change-detection.ts +0 -712
  187. package/src/core/config.ts +0 -313
  188. package/src/core/index.ts +0 -5
  189. package/src/core/move-detection.ts +0 -169
  190. package/src/core/snapshot.ts +0 -275
  191. package/src/core/sync-engine.ts +0 -1795
  192. package/src/index.ts +0 -4
  193. package/src/types/config.ts +0 -111
  194. package/src/types/documents.ts +0 -91
  195. package/src/types/index.ts +0 -3
  196. package/src/types/snapshot.ts +0 -67
  197. package/src/utils/content.ts +0 -34
  198. package/src/utils/directory.ts +0 -73
  199. package/src/utils/fs.ts +0 -297
  200. package/src/utils/index.ts +0 -4
  201. package/src/utils/mime-types.ts +0 -244
  202. package/src/utils/network-sync.ts +0 -319
  203. package/src/utils/node-polyfills.ts +0 -8
  204. package/src/utils/output.ts +0 -450
  205. package/src/utils/repo-factory.ts +0 -73
  206. package/src/utils/string-similarity.ts +0 -54
  207. package/src/utils/text-diff.ts +0 -101
  208. package/src/utils/trace.ts +0 -70
  209. package/test/integration/README.md +0 -328
  210. package/test/integration/clone-test.sh +0 -310
  211. package/test/integration/conflict-resolution-test.sh +0 -309
  212. package/test/integration/debug-both-nested.sh +0 -74
  213. package/test/integration/debug-concurrent-nested.sh +0 -87
  214. package/test/integration/debug-nested.sh +0 -73
  215. package/test/integration/deletion-behavior-test.sh +0 -487
  216. package/test/integration/deletion-sync-test-simple.sh +0 -193
  217. package/test/integration/deletion-sync-test.sh +0 -297
  218. package/test/integration/exclude-patterns.test.ts +0 -144
  219. package/test/integration/full-integration-test.sh +0 -363
  220. package/test/integration/fuzzer.test.ts +0 -818
  221. package/test/integration/in-memory-sync.test.ts +0 -830
  222. package/test/integration/init-sync.test.ts +0 -89
  223. package/test/integration/manual-sync-test.sh +0 -84
  224. package/test/integration/sync-deletion.test.ts +0 -280
  225. package/test/integration/sync-flow.test.ts +0 -291
  226. package/test/jest.setup.ts +0 -34
  227. package/test/run-tests.sh +0 -225
  228. package/test/unit/deletion-behavior.test.ts +0 -249
  229. package/test/unit/enhanced-mime-detection.test.ts +0 -244
  230. package/test/unit/snapshot.test.ts +0 -404
  231. package/test/unit/sync-convergence.test.ts +0 -298
  232. package/test/unit/sync-timing.test.ts +0 -134
  233. package/test/unit/utils.test.ts +0 -366
  234. package/tsconfig.json +0 -23
@@ -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
- });