pushwork 1.0.0 → 1.0.3
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/README.md +23 -21
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +114 -4
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli.js +27 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/change-detection.d.ts.map +1 -1
- package/dist/core/change-detection.js +27 -9
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +8 -2
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +4 -0
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +263 -7
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/documents.d.ts +2 -0
- package/dist/types/documents.d.ts.map +1 -1
- package/dist/types/documents.js.map +1 -1
- package/dist/utils/fs.d.ts.map +1 -1
- package/dist/utils/fs.js +7 -1
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +16 -3
- package/dist/utils/network-sync.js.map +1 -1
- package/package.json +30 -30
- package/src/cli/commands.ts +162 -8
- package/src/cli.ts +40 -0
- package/src/core/change-detection.ts +25 -12
- package/src/core/move-detection.ts +8 -2
- package/src/core/sync-engine.ts +270 -7
- package/src/types/documents.ts +2 -0
- package/src/utils/fs.ts +7 -3
- package/src/utils/network-sync.ts +19 -3
- package/test/integration/clone-test.sh +0 -0
- package/test/integration/conflict-resolution-test.sh +0 -0
- package/test/integration/debug-both-nested.sh +74 -0
- package/test/integration/debug-concurrent-nested.sh +87 -0
- package/test/integration/debug-nested.sh +73 -0
- package/test/integration/deletion-behavior-test.sh +0 -0
- package/test/integration/deletion-sync-test-simple.sh +0 -0
- package/test/integration/deletion-sync-test.sh +0 -0
- package/test/integration/full-integration-test.sh +0 -0
- package/test/integration/fuzzer.test.ts +865 -0
- package/test/integration/manual-sync-test.sh +84 -0
- package/test/run-tests.sh +0 -0
- package/test/unit/sync-convergence.test.ts +493 -0
- package/tools/browser-sync/README.md +0 -116
- package/tools/browser-sync/package.json +0 -44
- package/tools/browser-sync/patchwork.json +0 -1
- package/tools/browser-sync/pnpm-lock.yaml +0 -4202
- package/tools/browser-sync/src/components/BrowserSyncTool.tsx +0 -599
- package/tools/browser-sync/src/index.ts +0 -20
- package/tools/browser-sync/src/polyfills.ts +0 -31
- package/tools/browser-sync/src/styles.css +0 -290
- package/tools/browser-sync/src/types.ts +0 -27
- package/tools/browser-sync/vite.config.ts +0 -25
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
checkout,
|
|
13
13
|
commit,
|
|
14
14
|
url,
|
|
15
|
+
debug,
|
|
15
16
|
} from "./cli/commands";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -262,6 +263,34 @@ Note: This command outputs only the URL, making it useful for scripts.`
|
|
|
262
263
|
})
|
|
263
264
|
);
|
|
264
265
|
|
|
266
|
+
// Debug command
|
|
267
|
+
program
|
|
268
|
+
.command("debug")
|
|
269
|
+
.description("Show internal debug information including lastSyncAt timestamp")
|
|
270
|
+
.argument("[path]", "Directory path", ".")
|
|
271
|
+
.option(
|
|
272
|
+
"-v, --verbose",
|
|
273
|
+
"Show verbose debug information including full document contents"
|
|
274
|
+
)
|
|
275
|
+
.addHelpText(
|
|
276
|
+
"after",
|
|
277
|
+
`
|
|
278
|
+
Examples:
|
|
279
|
+
pushwork debug # Show debug info for current directory
|
|
280
|
+
pushwork debug --verbose # Show verbose debug info including full document contents
|
|
281
|
+
pushwork debug ./repo # Show debug info for specific directory
|
|
282
|
+
|
|
283
|
+
This command displays internal document state, including the lastSyncAt timestamp
|
|
284
|
+
that gets updated when sync operations make changes.`
|
|
285
|
+
)
|
|
286
|
+
.action(
|
|
287
|
+
withErrorHandling(async (path: string, options) => {
|
|
288
|
+
await debug(path, {
|
|
289
|
+
verbose: options.verbose || false,
|
|
290
|
+
});
|
|
291
|
+
})
|
|
292
|
+
);
|
|
293
|
+
|
|
265
294
|
// Global error handler
|
|
266
295
|
process.on("unhandledRejection", (reason, promise) => {
|
|
267
296
|
console.error(
|
|
@@ -274,6 +303,17 @@ process.on("unhandledRejection", (reason, promise) => {
|
|
|
274
303
|
});
|
|
275
304
|
|
|
276
305
|
process.on("uncaughtException", (error) => {
|
|
306
|
+
// Ignore WebSocket errors during shutdown - they're non-critical
|
|
307
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
308
|
+
if (
|
|
309
|
+
errorMessage.includes("WebSocket") ||
|
|
310
|
+
errorMessage.includes("connection was established") ||
|
|
311
|
+
errorMessage.includes("was closed")
|
|
312
|
+
) {
|
|
313
|
+
// Silently ignore WebSocket shutdown errors
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
277
317
|
console.error(chalk.red("Uncaught Exception:"), error);
|
|
278
318
|
process.exit(1);
|
|
279
319
|
});
|
|
@@ -268,27 +268,40 @@ export class ChangeDetector {
|
|
|
268
268
|
if (!existingEntry) {
|
|
269
269
|
// This is a remote file not in our snapshot
|
|
270
270
|
const localContent = await this.getLocalContent(entryPath);
|
|
271
|
+
const remoteContent = await this.getCurrentRemoteContent(entry.url);
|
|
272
|
+
const remoteHead = await this.getCurrentRemoteHead(entry.url);
|
|
271
273
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// (they were likely deleted and directory documents haven't been cleaned up yet)
|
|
275
|
-
if (localContent) {
|
|
276
|
-
// File exists locally but not in snapshot - this is a new local file
|
|
277
|
-
const remoteContent = await this.getCurrentRemoteContent(
|
|
278
|
-
entry.url
|
|
279
|
-
);
|
|
280
|
-
|
|
274
|
+
if (localContent && remoteContent) {
|
|
275
|
+
// File exists both locally and remotely but not in snapshot
|
|
281
276
|
changes.push({
|
|
282
277
|
path: entryPath,
|
|
283
278
|
changeType: ChangeType.BOTH_CHANGED,
|
|
284
279
|
fileType: await this.getFileTypeFromContent(remoteContent),
|
|
285
280
|
localContent,
|
|
286
281
|
remoteContent,
|
|
287
|
-
remoteHead
|
|
282
|
+
remoteHead,
|
|
283
|
+
});
|
|
284
|
+
} else if (localContent !== null && remoteContent === null) {
|
|
285
|
+
// File exists locally but not remotely (shouldn't happen in this flow)
|
|
286
|
+
changes.push({
|
|
287
|
+
path: entryPath,
|
|
288
|
+
changeType: ChangeType.LOCAL_ONLY,
|
|
289
|
+
fileType: await this.getFileTypeFromContent(localContent),
|
|
290
|
+
localContent,
|
|
291
|
+
remoteContent: null,
|
|
292
|
+
});
|
|
293
|
+
} else if (localContent === null && remoteContent !== null) {
|
|
294
|
+
// File exists remotely but not locally - this is what we need for clone!
|
|
295
|
+
changes.push({
|
|
296
|
+
path: entryPath,
|
|
297
|
+
changeType: ChangeType.REMOTE_ONLY,
|
|
298
|
+
fileType: await this.getFileTypeFromContent(remoteContent),
|
|
299
|
+
localContent: null,
|
|
300
|
+
remoteContent,
|
|
301
|
+
remoteHead,
|
|
288
302
|
});
|
|
289
303
|
}
|
|
290
|
-
//
|
|
291
|
-
// This prevents infinite sync loops with ghost entries from stale directory documents
|
|
304
|
+
// Only ignore if neither local nor remote content exists (ghost entry)
|
|
292
305
|
}
|
|
293
306
|
} else if (entry.type === "folder") {
|
|
294
307
|
// Recursively process subdirectory
|
|
@@ -47,13 +47,17 @@ export class MoveDetector {
|
|
|
47
47
|
deletedFile,
|
|
48
48
|
snapshot
|
|
49
49
|
);
|
|
50
|
-
|
|
50
|
+
// CRITICAL: Check for null explicitly, not falsy values
|
|
51
|
+
// Empty strings "" are valid file content!
|
|
52
|
+
if (deletedContent === null) continue;
|
|
51
53
|
|
|
52
54
|
let bestMatch: { file: DetectedChange; similarity: number } | null = null;
|
|
53
55
|
|
|
54
56
|
for (const createdFile of createdFiles) {
|
|
55
57
|
if (usedCreations.has(createdFile.path)) continue;
|
|
56
|
-
|
|
58
|
+
// CRITICAL: Check for null explicitly, not falsy values
|
|
59
|
+
// Empty strings "" are valid file content!
|
|
60
|
+
if (createdFile.localContent === null) continue;
|
|
57
61
|
|
|
58
62
|
const similarity = await ContentSimilarity.calculateSimilarity(
|
|
59
63
|
deletedContent,
|
|
@@ -78,6 +82,8 @@ export class MoveDetector {
|
|
|
78
82
|
toPath: bestMatch.file.path,
|
|
79
83
|
similarity: bestMatch.similarity,
|
|
80
84
|
confidence,
|
|
85
|
+
// Capture new content (may include modifications)
|
|
86
|
+
newContent: bestMatch.file.localContent || undefined,
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
// Only consume the deletion/creation pair when we would auto-apply the move.
|
package/src/core/sync-engine.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const myers = require("myers-diff");
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
AutomergeUrl,
|
|
3
5
|
Repo,
|
|
@@ -150,6 +152,13 @@ export class SyncEngine {
|
|
|
150
152
|
result.errors.push(...commitResult.errors);
|
|
151
153
|
result.warnings.push(...commitResult.warnings);
|
|
152
154
|
|
|
155
|
+
// Touch root directory if any changes were made
|
|
156
|
+
const hasChanges =
|
|
157
|
+
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
158
|
+
if (hasChanges) {
|
|
159
|
+
await this.touchRootDirectory(snapshot, dryRun);
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
// Save updated snapshot if not dry run
|
|
154
163
|
if (!dryRun) {
|
|
155
164
|
await this.snapshotManager.save(snapshot);
|
|
@@ -176,6 +185,9 @@ export class SyncEngine {
|
|
|
176
185
|
* Run full bidirectional sync
|
|
177
186
|
*/
|
|
178
187
|
async sync(dryRun = false): Promise<SyncResult> {
|
|
188
|
+
const syncStartTime = Date.now();
|
|
189
|
+
const timings: { [key: string]: number } = {};
|
|
190
|
+
|
|
179
191
|
const result: SyncResult = {
|
|
180
192
|
success: false,
|
|
181
193
|
filesChanged: 0,
|
|
@@ -189,37 +201,47 @@ export class SyncEngine {
|
|
|
189
201
|
|
|
190
202
|
try {
|
|
191
203
|
// Load current snapshot
|
|
204
|
+
const t0 = Date.now();
|
|
192
205
|
let snapshot = await this.snapshotManager.load();
|
|
206
|
+
timings["load_snapshot"] = Date.now() - t0;
|
|
193
207
|
if (!snapshot) {
|
|
194
208
|
snapshot = this.snapshotManager.createEmpty();
|
|
195
209
|
}
|
|
196
210
|
|
|
197
211
|
// Backup snapshot before starting
|
|
212
|
+
const t1 = Date.now();
|
|
198
213
|
if (!dryRun) {
|
|
199
214
|
await this.snapshotManager.backup();
|
|
200
215
|
}
|
|
216
|
+
timings["backup_snapshot"] = Date.now() - t1;
|
|
201
217
|
|
|
202
218
|
// Detect all changes
|
|
219
|
+
const t2 = Date.now();
|
|
203
220
|
const changes = await this.changeDetector.detectChanges(snapshot);
|
|
221
|
+
timings["detect_changes"] = Date.now() - t2;
|
|
204
222
|
|
|
205
223
|
// Detect moves
|
|
224
|
+
const t3 = Date.now();
|
|
206
225
|
const { moves, remainingChanges } = await this.moveDetector.detectMoves(
|
|
207
226
|
changes,
|
|
208
227
|
snapshot,
|
|
209
228
|
this.rootPath
|
|
210
229
|
);
|
|
230
|
+
timings["detect_moves"] = Date.now() - t3;
|
|
211
231
|
|
|
212
232
|
if (changes.length > 0) {
|
|
213
233
|
console.log(`🔄 Syncing ${changes.length} changes...`);
|
|
214
234
|
}
|
|
215
235
|
|
|
216
236
|
// Phase 1: Push local changes to remote
|
|
237
|
+
const t4 = Date.now();
|
|
217
238
|
const phase1Result = await this.pushLocalChanges(
|
|
218
239
|
remainingChanges,
|
|
219
240
|
moves,
|
|
220
241
|
snapshot,
|
|
221
242
|
dryRun
|
|
222
243
|
);
|
|
244
|
+
timings["phase1_push"] = Date.now() - t4;
|
|
223
245
|
|
|
224
246
|
result.filesChanged += phase1Result.filesChanged;
|
|
225
247
|
result.directoriesChanged += phase1Result.directoriesChanged;
|
|
@@ -228,6 +250,7 @@ export class SyncEngine {
|
|
|
228
250
|
|
|
229
251
|
// Always wait for network sync when enabled (not just when local changes exist)
|
|
230
252
|
// This is critical for clone scenarios where we need to pull remote changes
|
|
253
|
+
const t5 = Date.now();
|
|
231
254
|
if (!dryRun && this.networkSyncEnabled) {
|
|
232
255
|
try {
|
|
233
256
|
// If we have a root directory URL, wait for it to sync
|
|
@@ -239,41 +262,136 @@ export class SyncEngine {
|
|
|
239
262
|
}
|
|
240
263
|
|
|
241
264
|
if (this.handlesToWaitOn.length > 0) {
|
|
265
|
+
const tWaitStart = Date.now();
|
|
242
266
|
await waitForSync(
|
|
243
267
|
this.handlesToWaitOn,
|
|
244
268
|
getSyncServerStorageId(this.syncServerStorageId)
|
|
245
269
|
);
|
|
270
|
+
timings["network_sync"] = Date.now() - tWaitStart;
|
|
271
|
+
|
|
272
|
+
// CRITICAL: Wait a bit after our changes reach the server to allow
|
|
273
|
+
// time for WebSocket to deliver OTHER peers' changes to us.
|
|
274
|
+
// waitForSync only ensures OUR changes reached the server, not that
|
|
275
|
+
// we've RECEIVED changes from other peers. This delay allows the
|
|
276
|
+
// WebSocket protocol to propagate peer changes before we re-detect.
|
|
277
|
+
// Without this, concurrent operations on different peers can miss
|
|
278
|
+
// each other due to timing races.
|
|
279
|
+
//
|
|
280
|
+
// Optimization: Only wait if we pushed changes (shorter delay if no changes)
|
|
281
|
+
const tDelayStart = Date.now();
|
|
282
|
+
const delayMs = phase1Result.filesChanged > 0 ? 200 : 100;
|
|
283
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
284
|
+
timings["post_sync_delay"] = Date.now() - tDelayStart;
|
|
246
285
|
}
|
|
247
286
|
} catch (error) {
|
|
248
287
|
console.error(`❌ Network sync failed: ${error}`);
|
|
249
288
|
result.warnings.push(`Network sync failed: ${error}`);
|
|
250
289
|
}
|
|
251
290
|
}
|
|
291
|
+
timings["total_network"] = Date.now() - t5;
|
|
252
292
|
|
|
253
293
|
// Re-detect remote changes after network sync to ensure fresh state
|
|
254
294
|
// This fixes race conditions where we detect changes before server propagation
|
|
295
|
+
// NOTE: We DON'T update snapshot heads yet - that would prevent detecting remote changes!
|
|
296
|
+
const t6 = Date.now();
|
|
255
297
|
const freshChanges = await this.changeDetector.detectChanges(snapshot);
|
|
256
298
|
const freshRemoteChanges = freshChanges.filter(
|
|
257
299
|
(c) =>
|
|
258
300
|
c.changeType === ChangeType.REMOTE_ONLY ||
|
|
259
301
|
c.changeType === ChangeType.BOTH_CHANGED
|
|
260
302
|
);
|
|
303
|
+
timings["redetect_changes"] = Date.now() - t6;
|
|
261
304
|
|
|
262
305
|
// Phase 2: Pull remote changes to local using fresh detection
|
|
306
|
+
const t7 = Date.now();
|
|
263
307
|
const phase2Result = await this.pullRemoteChanges(
|
|
264
308
|
freshRemoteChanges,
|
|
265
309
|
snapshot,
|
|
266
310
|
dryRun
|
|
267
311
|
);
|
|
312
|
+
timings["phase2_pull"] = Date.now() - t7;
|
|
268
313
|
result.filesChanged += phase2Result.filesChanged;
|
|
269
314
|
result.directoriesChanged += phase2Result.directoriesChanged;
|
|
270
315
|
result.errors.push(...phase2Result.errors);
|
|
271
316
|
result.warnings.push(...phase2Result.warnings);
|
|
272
317
|
|
|
318
|
+
// CRITICAL FIX: Update snapshot heads AFTER pulling remote changes
|
|
319
|
+
// This ensures that change detection can find remote changes, and we only
|
|
320
|
+
// update the snapshot after the filesystem is in sync with the documents
|
|
321
|
+
const t8 = Date.now();
|
|
322
|
+
if (!dryRun) {
|
|
323
|
+
// Update file document heads
|
|
324
|
+
for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
|
|
325
|
+
try {
|
|
326
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
327
|
+
const currentHeads = handle.heads();
|
|
328
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
329
|
+
// Update snapshot with current heads after pulling changes
|
|
330
|
+
snapshot.files.set(filePath, {
|
|
331
|
+
...snapshotEntry,
|
|
332
|
+
head: currentHeads,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// Handle might not exist if file was deleted, skip
|
|
337
|
+
console.warn(`Could not update heads for ${filePath}: ${error}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Update directory document heads
|
|
342
|
+
for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
|
|
343
|
+
try {
|
|
344
|
+
const handle = await this.repo.find(snapshotEntry.url);
|
|
345
|
+
const currentHeads = handle.heads();
|
|
346
|
+
if (!A.equals(currentHeads, snapshotEntry.head)) {
|
|
347
|
+
// Update snapshot with current heads after pulling changes
|
|
348
|
+
snapshot.directories.set(dirPath, {
|
|
349
|
+
...snapshotEntry,
|
|
350
|
+
head: currentHeads,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
// Handle might not exist if directory was deleted, skip
|
|
355
|
+
console.warn(
|
|
356
|
+
`Could not update heads for directory ${dirPath}: ${error}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
timings["update_snapshot_heads"] = Date.now() - t8;
|
|
362
|
+
|
|
363
|
+
// Touch root directory if any changes were made during sync
|
|
364
|
+
const t9 = Date.now();
|
|
365
|
+
const hasChanges =
|
|
366
|
+
result.filesChanged > 0 || result.directoriesChanged > 0;
|
|
367
|
+
if (hasChanges) {
|
|
368
|
+
await this.touchRootDirectory(snapshot, dryRun);
|
|
369
|
+
}
|
|
370
|
+
timings["touch_root"] = Date.now() - t9;
|
|
371
|
+
|
|
273
372
|
// Save updated snapshot if not dry run
|
|
373
|
+
const t10 = Date.now();
|
|
274
374
|
if (!dryRun) {
|
|
275
375
|
await this.snapshotManager.save(snapshot);
|
|
276
376
|
}
|
|
377
|
+
timings["save_snapshot"] = Date.now() - t10;
|
|
378
|
+
|
|
379
|
+
// Output timing breakdown if enabled via environment variable
|
|
380
|
+
if (process.env.PUSHWORK_TIMING === "1") {
|
|
381
|
+
const totalTime = Date.now() - syncStartTime;
|
|
382
|
+
console.error("\n⏱️ Sync Timing Breakdown:");
|
|
383
|
+
for (const [key, ms] of Object.entries(timings)) {
|
|
384
|
+
const pct = ((ms / totalTime) * 100).toFixed(1);
|
|
385
|
+
console.error(
|
|
386
|
+
` ${key.padEnd(25)} ${ms.toString().padStart(5)}ms (${pct}%)`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
console.error(
|
|
390
|
+
` ${"TOTAL".padEnd(25)} ${totalTime
|
|
391
|
+
.toString()
|
|
392
|
+
.padStart(5)}ms (100.0%)\n`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
277
395
|
|
|
278
396
|
result.success = result.errors.length === 0;
|
|
279
397
|
return result;
|
|
@@ -407,7 +525,9 @@ export class SyncEngine {
|
|
|
407
525
|
): Promise<void> {
|
|
408
526
|
const snapshotEntry = snapshot.files.get(change.path);
|
|
409
527
|
|
|
410
|
-
|
|
528
|
+
// CRITICAL: Check for null explicitly, not falsy values
|
|
529
|
+
// Empty strings "" and empty Uint8Array are valid file content!
|
|
530
|
+
if (change.localContent === null) {
|
|
411
531
|
// File was deleted locally
|
|
412
532
|
if (snapshotEntry) {
|
|
413
533
|
console.log(`🗑️ ${change.path}`);
|
|
@@ -438,6 +558,8 @@ export class SyncEngine {
|
|
|
438
558
|
dryRun
|
|
439
559
|
);
|
|
440
560
|
|
|
561
|
+
// CRITICAL FIX: Update snapshot with heads AFTER adding to directory
|
|
562
|
+
// The addFileToDirectory call above may have changed the document heads
|
|
441
563
|
this.snapshotManager.updateFileEntry(snapshot, change.path, {
|
|
442
564
|
path: normalizePath(this.rootPath + "/" + change.path),
|
|
443
565
|
url: handle.url,
|
|
@@ -449,6 +571,32 @@ export class SyncEngine {
|
|
|
449
571
|
} else {
|
|
450
572
|
// Update existing file
|
|
451
573
|
console.log(`📝 ${change.path}`);
|
|
574
|
+
|
|
575
|
+
// log the change in detail for debugging
|
|
576
|
+
// split out remotea nd local content so we don't overwhelm the logs
|
|
577
|
+
const { remoteContent, localContent, ...rest } = change;
|
|
578
|
+
console.log(`🔍 Change in detail:`, rest);
|
|
579
|
+
|
|
580
|
+
// compare the local and remote content and make a diff so we can
|
|
581
|
+
// see what happened between the two
|
|
582
|
+
const { diff, changed } = require("myers-diff");
|
|
583
|
+
const lhs = change.remoteContent ? change.remoteContent.toString() : "";
|
|
584
|
+
const rhs = change.localContent ? change.localContent.toString() : "";
|
|
585
|
+
const changes = diff(lhs, rhs, { compare: "chars" });
|
|
586
|
+
|
|
587
|
+
for (const change of changes) {
|
|
588
|
+
if (changed(change.lhs)) {
|
|
589
|
+
// deleted
|
|
590
|
+
const { pos, text, del, length } = change.lhs;
|
|
591
|
+
console.log(`🔍 Deleted:`, { pos, text, del, length });
|
|
592
|
+
}
|
|
593
|
+
if (changed(change.rhs)) {
|
|
594
|
+
// added
|
|
595
|
+
const { pos, text, add, length } = change.rhs;
|
|
596
|
+
console.log(`🔍 Added:`, { pos, text, add, length });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
452
600
|
await this.updateRemoteFile(
|
|
453
601
|
snapshotEntry.url,
|
|
454
602
|
change.localContent,
|
|
@@ -471,11 +619,13 @@ export class SyncEngine {
|
|
|
471
619
|
|
|
472
620
|
if (!change.remoteHead) {
|
|
473
621
|
throw new Error(
|
|
474
|
-
`No remote head found for remote change to${change.path}`
|
|
622
|
+
`No remote head found for remote change to ${change.path}`
|
|
475
623
|
);
|
|
476
624
|
}
|
|
477
625
|
|
|
478
|
-
|
|
626
|
+
// CRITICAL: Check for null explicitly, not falsy values
|
|
627
|
+
// Empty strings "" and empty Uint8Array are valid file content!
|
|
628
|
+
if (change.remoteContent === null) {
|
|
479
629
|
// File was deleted remotely
|
|
480
630
|
console.log(`🗑️ ${change.path}`);
|
|
481
631
|
if (!dryRun) {
|
|
@@ -568,17 +718,39 @@ export class SyncEngine {
|
|
|
568
718
|
dryRun
|
|
569
719
|
);
|
|
570
720
|
|
|
571
|
-
// 3) Update the FileDocument name to match new
|
|
721
|
+
// 3) Update the FileDocument name and content to match new location/state
|
|
572
722
|
try {
|
|
573
723
|
const handle = await this.repo.find<FileDocument>(fromEntry.url);
|
|
574
724
|
const heads = fromEntry.head;
|
|
725
|
+
|
|
726
|
+
// Update both name and content (if content changed during move)
|
|
575
727
|
if (heads && heads.length > 0) {
|
|
576
728
|
handle.changeAt(heads, (doc: FileDocument) => {
|
|
577
729
|
doc.name = toFileName;
|
|
730
|
+
|
|
731
|
+
// If new content is provided, update it (handles move + modification case)
|
|
732
|
+
if (move.newContent !== undefined) {
|
|
733
|
+
const isText = this.isTextContent(move.newContent);
|
|
734
|
+
if (isText && typeof move.newContent === "string") {
|
|
735
|
+
updateText(doc, ["content"], move.newContent);
|
|
736
|
+
} else {
|
|
737
|
+
doc.content = move.newContent;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
578
740
|
});
|
|
579
741
|
} else {
|
|
580
742
|
handle.change((doc: FileDocument) => {
|
|
581
743
|
doc.name = toFileName;
|
|
744
|
+
|
|
745
|
+
// If new content is provided, update it (handles move + modification case)
|
|
746
|
+
if (move.newContent !== undefined) {
|
|
747
|
+
const isText = this.isTextContent(move.newContent);
|
|
748
|
+
if (isText && typeof move.newContent === "string") {
|
|
749
|
+
updateText(doc, ["content"], move.newContent);
|
|
750
|
+
} else {
|
|
751
|
+
doc.content = move.newContent;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
582
754
|
});
|
|
583
755
|
}
|
|
584
756
|
// Track file handle for network sync
|
|
@@ -606,7 +778,9 @@ export class SyncEngine {
|
|
|
606
778
|
change: DetectedChange,
|
|
607
779
|
dryRun: boolean
|
|
608
780
|
): Promise<DocHandle<FileDocument> | null> {
|
|
609
|
-
|
|
781
|
+
// CRITICAL: Check for null explicitly, not falsy values
|
|
782
|
+
// Empty strings "" and empty Uint8Array are valid file content!
|
|
783
|
+
if (dryRun || change.localContent === null) return null;
|
|
610
784
|
|
|
611
785
|
const isText = this.isTextContent(change.localContent);
|
|
612
786
|
|
|
@@ -657,11 +831,26 @@ export class SyncEngine {
|
|
|
657
831
|
const currentContent = doc?.content;
|
|
658
832
|
const contentChanged = !isContentEqual(content, currentContent);
|
|
659
833
|
|
|
834
|
+
// CRITICAL FIX: Always update snapshot heads, even when content is identical
|
|
835
|
+
// This prevents stale head issues that cause false change detection
|
|
836
|
+
const snapshotEntry = snapshot.files.get(filePath);
|
|
837
|
+
if (snapshotEntry) {
|
|
838
|
+
// Update snapshot with current document heads
|
|
839
|
+
snapshot.files.set(filePath, {
|
|
840
|
+
...snapshotEntry,
|
|
841
|
+
head: handle.heads(),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
660
845
|
if (!contentChanged) {
|
|
846
|
+
// Content is identical, but we've updated the snapshot heads above
|
|
847
|
+
// This prevents fresh change detection from seeing stale heads
|
|
848
|
+
console.log(
|
|
849
|
+
`🔍 Content is identical, but we've updated the snapshot heads above`
|
|
850
|
+
);
|
|
661
851
|
return;
|
|
662
852
|
}
|
|
663
853
|
|
|
664
|
-
const snapshotEntry = snapshot.files.get(filePath);
|
|
665
854
|
const heads = snapshotEntry?.head;
|
|
666
855
|
|
|
667
856
|
if (!heads) {
|
|
@@ -677,7 +866,8 @@ export class SyncEngine {
|
|
|
677
866
|
}
|
|
678
867
|
});
|
|
679
868
|
|
|
680
|
-
|
|
869
|
+
// Update snapshot with new heads after content change
|
|
870
|
+
if (!dryRun && snapshotEntry) {
|
|
681
871
|
snapshot.files.set(filePath, {
|
|
682
872
|
...snapshotEntry,
|
|
683
873
|
head: handle.heads(),
|
|
@@ -781,6 +971,12 @@ export class SyncEngine {
|
|
|
781
971
|
}
|
|
782
972
|
if (didChange) {
|
|
783
973
|
this.handlesToWaitOn.push(dirHandle);
|
|
974
|
+
|
|
975
|
+
// CRITICAL FIX: Update snapshot with new directory heads immediately
|
|
976
|
+
// This prevents stale head issues that cause convergence problems
|
|
977
|
+
if (snapshotEntry) {
|
|
978
|
+
snapshotEntry.head = dirHandle.heads();
|
|
979
|
+
}
|
|
784
980
|
}
|
|
785
981
|
}
|
|
786
982
|
|
|
@@ -900,6 +1096,13 @@ export class SyncEngine {
|
|
|
900
1096
|
this.handlesToWaitOn.push(dirHandle);
|
|
901
1097
|
if (didChange) {
|
|
902
1098
|
this.handlesToWaitOn.push(parentHandle);
|
|
1099
|
+
|
|
1100
|
+
// CRITICAL FIX: Update parent directory heads in snapshot immediately
|
|
1101
|
+
// This prevents stale head issues when parent directory is modified
|
|
1102
|
+
const parentSnapshotEntry = snapshot.directories.get(parentPath);
|
|
1103
|
+
if (parentSnapshotEntry) {
|
|
1104
|
+
parentSnapshotEntry.head = parentHandle.heads();
|
|
1105
|
+
}
|
|
903
1106
|
}
|
|
904
1107
|
|
|
905
1108
|
// Update snapshot with new directory
|
|
@@ -950,6 +1153,8 @@ export class SyncEngine {
|
|
|
950
1153
|
this.handlesToWaitOn.push(dirHandle);
|
|
951
1154
|
const snapshotEntry = snapshot.directories.get(directoryPath);
|
|
952
1155
|
const heads = snapshotEntry?.head;
|
|
1156
|
+
let didChange = false;
|
|
1157
|
+
|
|
953
1158
|
if (heads) {
|
|
954
1159
|
dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
|
|
955
1160
|
const indexToRemove = doc.docs.findIndex(
|
|
@@ -957,6 +1162,7 @@ export class SyncEngine {
|
|
|
957
1162
|
);
|
|
958
1163
|
if (indexToRemove !== -1) {
|
|
959
1164
|
doc.docs.splice(indexToRemove, 1);
|
|
1165
|
+
didChange = true;
|
|
960
1166
|
console.log(
|
|
961
1167
|
`🗑️ Removed ${fileName} from directory ${
|
|
962
1168
|
directoryPath || "root"
|
|
@@ -971,6 +1177,7 @@ export class SyncEngine {
|
|
|
971
1177
|
);
|
|
972
1178
|
if (indexToRemove !== -1) {
|
|
973
1179
|
doc.docs.splice(indexToRemove, 1);
|
|
1180
|
+
didChange = true;
|
|
974
1181
|
console.log(
|
|
975
1182
|
`🗑️ Removed ${fileName} from directory ${
|
|
976
1183
|
directoryPath || "root"
|
|
@@ -979,6 +1186,12 @@ export class SyncEngine {
|
|
|
979
1186
|
}
|
|
980
1187
|
});
|
|
981
1188
|
}
|
|
1189
|
+
|
|
1190
|
+
// CRITICAL FIX: Update snapshot with new directory heads immediately
|
|
1191
|
+
// This prevents stale head issues that cause convergence problems
|
|
1192
|
+
if (didChange && snapshotEntry) {
|
|
1193
|
+
snapshotEntry.head = dirHandle.heads();
|
|
1194
|
+
}
|
|
982
1195
|
} catch (error) {
|
|
983
1196
|
console.warn(
|
|
984
1197
|
`Failed to remove ${fileName} from directory ${
|
|
@@ -1164,4 +1377,54 @@ export class SyncEngine {
|
|
|
1164
1377
|
|
|
1165
1378
|
return parts.join(", ");
|
|
1166
1379
|
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Update the lastSyncAt timestamp on the root directory document
|
|
1383
|
+
*/
|
|
1384
|
+
private async touchRootDirectory(
|
|
1385
|
+
snapshot: SyncSnapshot,
|
|
1386
|
+
dryRun: boolean
|
|
1387
|
+
): Promise<void> {
|
|
1388
|
+
if (dryRun || !snapshot.rootDirectoryUrl) {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
try {
|
|
1393
|
+
const rootHandle = await this.repo.find<DirectoryDocument>(
|
|
1394
|
+
snapshot.rootDirectoryUrl
|
|
1395
|
+
);
|
|
1396
|
+
|
|
1397
|
+
const snapshotEntry = snapshot.directories.get("");
|
|
1398
|
+
const heads = snapshotEntry?.head;
|
|
1399
|
+
|
|
1400
|
+
const timestamp = Date.now();
|
|
1401
|
+
|
|
1402
|
+
if (heads) {
|
|
1403
|
+
rootHandle.changeAt(heads, (doc: DirectoryDocument) => {
|
|
1404
|
+
doc.lastSyncAt = timestamp;
|
|
1405
|
+
});
|
|
1406
|
+
} else {
|
|
1407
|
+
rootHandle.change((doc: DirectoryDocument) => {
|
|
1408
|
+
doc.lastSyncAt = timestamp;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Track root directory for network sync
|
|
1413
|
+
this.handlesToWaitOn.push(rootHandle);
|
|
1414
|
+
|
|
1415
|
+
// CRITICAL FIX: Update root directory heads in snapshot immediately
|
|
1416
|
+
// This prevents stale head issues when root directory is modified
|
|
1417
|
+
if (snapshotEntry) {
|
|
1418
|
+
snapshotEntry.head = rootHandle.heads();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
console.log(
|
|
1422
|
+
`🕒 Updated root directory lastSyncAt to ${new Date(
|
|
1423
|
+
timestamp
|
|
1424
|
+
).toISOString()}`
|
|
1425
|
+
);
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
console.warn(`Failed to update root directory lastSyncAt: ${error}`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1167
1430
|
}
|
package/src/types/documents.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface DirectoryEntry {
|
|
|
15
15
|
export interface DirectoryDocument {
|
|
16
16
|
"@patchwork": { type: "folder" };
|
|
17
17
|
docs: DirectoryEntry[];
|
|
18
|
+
lastSyncAt?: number; // Timestamp of last sync operation that made changes
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -69,4 +70,5 @@ export interface MoveCandidate {
|
|
|
69
70
|
toPath: string;
|
|
70
71
|
similarity: number;
|
|
71
72
|
confidence: "auto" | "prompt" | "low";
|
|
73
|
+
newContent?: string | Uint8Array; // Content at destination (may differ from source if modified during move)
|
|
72
74
|
}
|
package/src/utils/fs.ts
CHANGED
|
@@ -159,9 +159,13 @@ function isExcluded(
|
|
|
159
159
|
}
|
|
160
160
|
} else if (pattern.includes("*")) {
|
|
161
161
|
// Glob pattern like "*.tmp"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// CRITICAL FIX: Properly escape dots and anchor the pattern
|
|
163
|
+
// Convert glob to regex: *.tmp -> ^.*\.tmp$ (not /.*.tmp/ which matches "fuftmp.ts"!)
|
|
164
|
+
const regexPattern = pattern
|
|
165
|
+
.replace(/\./g, "\\.") // Escape dots first
|
|
166
|
+
.replace(/\*/g, ".*") // Then convert * to .*
|
|
167
|
+
.replace(/\?/g, "."); // And ? to single char
|
|
168
|
+
const regex = new RegExp(`^${regexPattern}$`); // Anchor to match full path
|
|
165
169
|
if (regex.test(relativePath)) {
|
|
166
170
|
return true;
|
|
167
171
|
}
|