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.
- package/dist/branches.d.ts +19 -0
- package/dist/branches.d.ts.map +1 -0
- package/dist/branches.js +111 -0
- package/dist/branches.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +238 -272
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +84 -0
- package/dist/config.js.map +1 -0
- package/dist/fs-tree.d.ts +6 -0
- package/dist/fs-tree.d.ts.map +1 -0
- package/dist/fs-tree.js +99 -0
- package/dist/fs-tree.js.map +1 -0
- package/dist/ignore.d.ts +6 -0
- package/dist/ignore.d.ts.map +1 -0
- package/dist/ignore.js +74 -0
- package/dist/ignore.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -4
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +14 -0
- package/dist/log.js.map +1 -0
- package/dist/pushwork.d.ts +115 -0
- package/dist/pushwork.d.ts.map +1 -0
- package/dist/pushwork.js +918 -0
- package/dist/pushwork.js.map +1 -0
- package/dist/repo.d.ts +14 -0
- package/dist/repo.d.ts.map +1 -0
- package/dist/repo.js +60 -0
- package/dist/repo.js.map +1 -0
- package/dist/shapes/custom.d.ts +3 -0
- package/dist/shapes/custom.d.ts.map +1 -0
- package/dist/shapes/custom.js +57 -0
- package/dist/shapes/custom.js.map +1 -0
- package/dist/shapes/file.d.ts +20 -0
- package/dist/shapes/file.d.ts.map +1 -0
- package/dist/shapes/file.js +140 -0
- package/dist/shapes/file.js.map +1 -0
- package/dist/shapes/index.d.ts +10 -0
- package/dist/shapes/index.d.ts.map +1 -0
- package/dist/shapes/index.js +35 -0
- package/dist/shapes/index.js.map +1 -0
- package/dist/shapes/patchwork-folder.d.ts +3 -0
- package/dist/shapes/patchwork-folder.d.ts.map +1 -0
- package/dist/shapes/patchwork-folder.js +160 -0
- package/dist/shapes/patchwork-folder.js.map +1 -0
- package/dist/shapes/types.d.ts +37 -0
- package/dist/shapes/types.d.ts.map +1 -0
- package/dist/shapes/types.js +52 -0
- package/dist/shapes/types.js.map +1 -0
- package/dist/shapes/vfs.d.ts +3 -0
- package/dist/shapes/vfs.d.ts.map +1 -0
- package/dist/shapes/vfs.js +88 -0
- package/dist/shapes/vfs.js.map +1 -0
- package/dist/stash.d.ts +23 -0
- package/dist/stash.d.ts.map +1 -0
- package/dist/stash.js +118 -0
- package/dist/stash.js.map +1 -0
- package/flake.lock +128 -0
- package/flake.nix +66 -0
- package/package.json +15 -48
- package/patches/@automerge__automerge-repo@2.6.0-subduction.15.patch +26 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/branches.ts +93 -0
- package/src/cli.ts +258 -408
- package/src/config.ts +64 -0
- package/src/fs-tree.ts +70 -0
- package/src/ignore.ts +33 -0
- package/src/index.ts +38 -4
- package/src/log.ts +8 -0
- package/src/pushwork.ts +1055 -0
- package/src/repo.ts +76 -0
- package/src/shapes/custom.ts +29 -0
- package/src/shapes/file.ts +115 -0
- package/src/shapes/index.ts +19 -0
- package/src/shapes/patchwork-folder.ts +156 -0
- package/src/shapes/types.ts +79 -0
- package/src/shapes/vfs.ts +93 -0
- package/src/stash.ts +106 -0
- package/test/integration/branches.test.ts +389 -0
- package/test/integration/pushwork.test.ts +547 -0
- package/test/setup.ts +29 -0
- package/test/unit/doc-shape.test.ts +612 -0
- package/tsconfig.json +2 -3
- package/vitest.config.ts +14 -0
- package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +0 -248
- package/CLAUDE.md +0 -141
- package/README.md +0 -221
- package/babel.config.js +0 -5
- package/dist/cli/commands.d.ts +0 -71
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -794
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -19
- package/dist/cli/index.js.map +0 -1
- package/dist/commands.d.ts +0 -61
- package/dist/commands.d.ts.map +0 -1
- package/dist/commands.js +0 -861
- package/dist/commands.js.map +0 -1
- package/dist/config/index.d.ts +0 -71
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -314
- package/dist/config/index.js.map +0 -1
- package/dist/core/change-detection.d.ts +0 -80
- package/dist/core/change-detection.d.ts.map +0 -1
- package/dist/core/change-detection.js +0 -523
- package/dist/core/change-detection.js.map +0 -1
- package/dist/core/config.d.ts +0 -81
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js +0 -258
- package/dist/core/config.js.map +0 -1
- package/dist/core/index.d.ts +0 -6
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js +0 -6
- package/dist/core/index.js.map +0 -1
- package/dist/core/move-detection.d.ts +0 -34
- package/dist/core/move-detection.d.ts.map +0 -1
- package/dist/core/move-detection.js +0 -121
- package/dist/core/move-detection.js.map +0 -1
- package/dist/core/snapshot.d.ts +0 -105
- package/dist/core/snapshot.d.ts.map +0 -1
- package/dist/core/snapshot.js +0 -217
- package/dist/core/snapshot.js.map +0 -1
- package/dist/core/sync-engine.d.ts +0 -157
- package/dist/core/sync-engine.d.ts.map +0 -1
- package/dist/core/sync-engine.js +0 -1379
- package/dist/core/sync-engine.js.map +0 -1
- package/dist/types/config.d.ts +0 -99
- package/dist/types/config.d.ts.map +0 -1
- package/dist/types/config.js +0 -5
- package/dist/types/config.js.map +0 -1
- package/dist/types/documents.d.ts +0 -88
- package/dist/types/documents.d.ts.map +0 -1
- package/dist/types/documents.js +0 -20
- package/dist/types/documents.js.map +0 -1
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -4
- package/dist/types/index.js.map +0 -1
- package/dist/types/snapshot.d.ts +0 -64
- package/dist/types/snapshot.d.ts.map +0 -1
- package/dist/types/snapshot.js +0 -2
- package/dist/types/snapshot.js.map +0 -1
- package/dist/utils/content-similarity.d.ts +0 -53
- package/dist/utils/content-similarity.d.ts.map +0 -1
- package/dist/utils/content-similarity.js +0 -155
- package/dist/utils/content-similarity.js.map +0 -1
- package/dist/utils/content.d.ts +0 -10
- package/dist/utils/content.d.ts.map +0 -1
- package/dist/utils/content.js +0 -31
- package/dist/utils/content.js.map +0 -1
- package/dist/utils/directory.d.ts +0 -24
- package/dist/utils/directory.d.ts.map +0 -1
- package/dist/utils/directory.js +0 -52
- package/dist/utils/directory.js.map +0 -1
- package/dist/utils/fs.d.ts +0 -74
- package/dist/utils/fs.d.ts.map +0 -1
- package/dist/utils/fs.js +0 -248
- package/dist/utils/fs.js.map +0 -1
- package/dist/utils/index.d.ts +0 -5
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -5
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/mime-types.d.ts +0 -13
- package/dist/utils/mime-types.d.ts.map +0 -1
- package/dist/utils/mime-types.js +0 -209
- package/dist/utils/mime-types.js.map +0 -1
- package/dist/utils/network-sync.d.ts +0 -36
- package/dist/utils/network-sync.d.ts.map +0 -1
- package/dist/utils/network-sync.js +0 -250
- package/dist/utils/network-sync.js.map +0 -1
- package/dist/utils/node-polyfills.d.ts +0 -9
- package/dist/utils/node-polyfills.d.ts.map +0 -1
- package/dist/utils/node-polyfills.js +0 -9
- package/dist/utils/node-polyfills.js.map +0 -1
- package/dist/utils/output.d.ts +0 -129
- package/dist/utils/output.d.ts.map +0 -1
- package/dist/utils/output.js +0 -368
- package/dist/utils/output.js.map +0 -1
- package/dist/utils/repo-factory.d.ts +0 -13
- package/dist/utils/repo-factory.d.ts.map +0 -1
- package/dist/utils/repo-factory.js +0 -46
- package/dist/utils/repo-factory.js.map +0 -1
- package/dist/utils/string-similarity.d.ts +0 -14
- package/dist/utils/string-similarity.d.ts.map +0 -1
- package/dist/utils/string-similarity.js +0 -39
- package/dist/utils/string-similarity.js.map +0 -1
- package/dist/utils/text-diff.d.ts +0 -37
- package/dist/utils/text-diff.d.ts.map +0 -1
- package/dist/utils/text-diff.js +0 -93
- package/dist/utils/text-diff.js.map +0 -1
- package/dist/utils/trace.d.ts +0 -19
- package/dist/utils/trace.d.ts.map +0 -1
- package/dist/utils/trace.js +0 -63
- package/dist/utils/trace.js.map +0 -1
- package/src/commands.ts +0 -1134
- package/src/core/change-detection.ts +0 -712
- package/src/core/config.ts +0 -313
- package/src/core/index.ts +0 -5
- package/src/core/move-detection.ts +0 -169
- package/src/core/snapshot.ts +0 -275
- package/src/core/sync-engine.ts +0 -1795
- package/src/types/config.ts +0 -111
- package/src/types/documents.ts +0 -91
- package/src/types/index.ts +0 -3
- package/src/types/snapshot.ts +0 -67
- package/src/utils/content.ts +0 -34
- package/src/utils/directory.ts +0 -73
- package/src/utils/fs.ts +0 -297
- package/src/utils/index.ts +0 -4
- package/src/utils/mime-types.ts +0 -244
- package/src/utils/network-sync.ts +0 -319
- package/src/utils/node-polyfills.ts +0 -8
- package/src/utils/output.ts +0 -450
- package/src/utils/repo-factory.ts +0 -73
- package/src/utils/string-similarity.ts +0 -54
- package/src/utils/text-diff.ts +0 -101
- package/src/utils/trace.ts +0 -70
- package/test/integration/README.md +0 -328
- package/test/integration/clone-test.sh +0 -310
- package/test/integration/conflict-resolution-test.sh +0 -309
- package/test/integration/debug-both-nested.sh +0 -74
- package/test/integration/debug-concurrent-nested.sh +0 -87
- package/test/integration/debug-nested.sh +0 -73
- package/test/integration/deletion-behavior-test.sh +0 -487
- package/test/integration/deletion-sync-test-simple.sh +0 -193
- package/test/integration/deletion-sync-test.sh +0 -297
- package/test/integration/exclude-patterns.test.ts +0 -144
- package/test/integration/full-integration-test.sh +0 -363
- package/test/integration/fuzzer.test.ts +0 -818
- package/test/integration/in-memory-sync.test.ts +0 -830
- package/test/integration/init-sync.test.ts +0 -89
- package/test/integration/manual-sync-test.sh +0 -84
- package/test/integration/sync-deletion.test.ts +0 -280
- package/test/integration/sync-flow.test.ts +0 -291
- package/test/jest.setup.ts +0 -34
- package/test/run-tests.sh +0 -225
- package/test/unit/deletion-behavior.test.ts +0 -249
- package/test/unit/enhanced-mime-detection.test.ts +0 -244
- package/test/unit/snapshot.test.ts +0 -404
- package/test/unit/sync-convergence.test.ts +0 -298
- package/test/unit/sync-timing.test.ts +0 -134
- 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
|
-
});
|