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.
Files changed (58) hide show
  1. package/README.md +23 -21
  2. package/dist/cli/commands.d.ts +6 -0
  3. package/dist/cli/commands.d.ts.map +1 -1
  4. package/dist/cli/commands.js +114 -4
  5. package/dist/cli/commands.js.map +1 -1
  6. package/dist/cli.js +27 -0
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/change-detection.d.ts.map +1 -1
  9. package/dist/core/change-detection.js +27 -9
  10. package/dist/core/change-detection.js.map +1 -1
  11. package/dist/core/move-detection.d.ts.map +1 -1
  12. package/dist/core/move-detection.js +8 -2
  13. package/dist/core/move-detection.js.map +1 -1
  14. package/dist/core/sync-engine.d.ts +4 -0
  15. package/dist/core/sync-engine.d.ts.map +1 -1
  16. package/dist/core/sync-engine.js +263 -7
  17. package/dist/core/sync-engine.js.map +1 -1
  18. package/dist/types/documents.d.ts +2 -0
  19. package/dist/types/documents.d.ts.map +1 -1
  20. package/dist/types/documents.js.map +1 -1
  21. package/dist/utils/fs.d.ts.map +1 -1
  22. package/dist/utils/fs.js +7 -1
  23. package/dist/utils/fs.js.map +1 -1
  24. package/dist/utils/network-sync.d.ts.map +1 -1
  25. package/dist/utils/network-sync.js +16 -3
  26. package/dist/utils/network-sync.js.map +1 -1
  27. package/package.json +30 -30
  28. package/src/cli/commands.ts +162 -8
  29. package/src/cli.ts +40 -0
  30. package/src/core/change-detection.ts +25 -12
  31. package/src/core/move-detection.ts +8 -2
  32. package/src/core/sync-engine.ts +270 -7
  33. package/src/types/documents.ts +2 -0
  34. package/src/utils/fs.ts +7 -3
  35. package/src/utils/network-sync.ts +19 -3
  36. package/test/integration/clone-test.sh +0 -0
  37. package/test/integration/conflict-resolution-test.sh +0 -0
  38. package/test/integration/debug-both-nested.sh +74 -0
  39. package/test/integration/debug-concurrent-nested.sh +87 -0
  40. package/test/integration/debug-nested.sh +73 -0
  41. package/test/integration/deletion-behavior-test.sh +0 -0
  42. package/test/integration/deletion-sync-test-simple.sh +0 -0
  43. package/test/integration/deletion-sync-test.sh +0 -0
  44. package/test/integration/full-integration-test.sh +0 -0
  45. package/test/integration/fuzzer.test.ts +865 -0
  46. package/test/integration/manual-sync-test.sh +84 -0
  47. package/test/run-tests.sh +0 -0
  48. package/test/unit/sync-convergence.test.ts +493 -0
  49. package/tools/browser-sync/README.md +0 -116
  50. package/tools/browser-sync/package.json +0 -44
  51. package/tools/browser-sync/patchwork.json +0 -1
  52. package/tools/browser-sync/pnpm-lock.yaml +0 -4202
  53. package/tools/browser-sync/src/components/BrowserSyncTool.tsx +0 -599
  54. package/tools/browser-sync/src/index.ts +0 -20
  55. package/tools/browser-sync/src/polyfills.ts +0 -31
  56. package/tools/browser-sync/src/styles.css +0 -290
  57. package/tools/browser-sync/src/types.ts +0 -27
  58. 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
- // Only create changes for files that exist locally
273
- // Files that don't exist locally AND aren't in snapshot should be ignored
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: await this.getCurrentRemoteHead(entry.url),
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
- // If file doesn't exist locally and isn't in snapshot, ignore it
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
- if (!deletedContent) continue;
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
- if (!createdFile.localContent) continue;
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.
@@ -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
- if (!change.localContent) {
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
- if (!change.remoteContent) {
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 basename
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
- if (dryRun || !change.localContent) return null;
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
- if (!dryRun) {
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
  }
@@ -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
- const regex = new RegExp(
163
- pattern.replace(/\*/g, ".*").replace(/\?/g, ".")
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
  }