pushwork 1.0.15 → 1.0.17

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 (71) hide show
  1. package/CLAUDE.md +114 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands.d.ts.map +1 -1
  6. package/dist/commands.js +38 -11
  7. package/dist/commands.js.map +1 -1
  8. package/dist/core/change-detection.d.ts.map +1 -1
  9. package/dist/core/change-detection.js +4 -12
  10. package/dist/core/change-detection.js.map +1 -1
  11. package/dist/core/config.d.ts +1 -1
  12. package/dist/core/config.d.ts.map +1 -1
  13. package/dist/core/config.js +3 -3
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/sync-engine.d.ts +25 -40
  16. package/dist/core/sync-engine.d.ts.map +1 -1
  17. package/dist/core/sync-engine.js +293 -317
  18. package/dist/core/sync-engine.js.map +1 -1
  19. package/dist/types/config.d.ts +1 -0
  20. package/dist/types/config.d.ts.map +1 -1
  21. package/dist/types/documents.d.ts +1 -2
  22. package/dist/types/documents.d.ts.map +1 -1
  23. package/dist/types/documents.js.map +1 -1
  24. package/dist/utils/fs.d.ts +2 -3
  25. package/dist/utils/fs.d.ts.map +1 -1
  26. package/dist/utils/fs.js +1 -11
  27. package/dist/utils/fs.js.map +1 -1
  28. package/dist/utils/index.d.ts +1 -0
  29. package/dist/utils/index.d.ts.map +1 -1
  30. package/dist/utils/index.js +1 -0
  31. package/dist/utils/index.js.map +1 -1
  32. package/dist/utils/network-sync.d.ts.map +1 -1
  33. package/dist/utils/network-sync.js +22 -6
  34. package/dist/utils/network-sync.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/cli.ts +12 -0
  37. package/src/commands.ts +41 -11
  38. package/src/core/change-detection.ts +557 -564
  39. package/src/core/config.ts +3 -3
  40. package/src/core/sync-engine.ts +1418 -1427
  41. package/src/types/config.ts +1 -0
  42. package/src/types/documents.ts +38 -39
  43. package/src/utils/fs.ts +170 -178
  44. package/src/utils/index.ts +4 -3
  45. package/src/utils/network-sync.ts +25 -6
  46. package/src/utils/text-diff.ts +101 -0
  47. package/test/integration/fuzzer.test.ts +1 -1
  48. package/dist/cli/commands.d.ts +0 -61
  49. package/dist/cli/commands.d.ts.map +0 -1
  50. package/dist/cli/commands.js +0 -661
  51. package/dist/cli/commands.js.map +0 -1
  52. package/dist/cli/index.d.ts +0 -2
  53. package/dist/cli/index.d.ts.map +0 -1
  54. package/dist/cli/index.js +0 -19
  55. package/dist/cli/index.js.map +0 -1
  56. package/dist/cli/output.d.ts +0 -61
  57. package/dist/cli/output.d.ts.map +0 -1
  58. package/dist/cli/output.js +0 -176
  59. package/dist/cli/output.js.map +0 -1
  60. package/dist/config/index.d.ts +0 -71
  61. package/dist/config/index.d.ts.map +0 -1
  62. package/dist/config/index.js +0 -314
  63. package/dist/config/index.js.map +0 -1
  64. package/dist/utils/content-similarity.d.ts +0 -53
  65. package/dist/utils/content-similarity.d.ts.map +0 -1
  66. package/dist/utils/content-similarity.js +0 -155
  67. package/dist/utils/content-similarity.js.map +0 -1
  68. package/dist/utils/keyhive.d.ts +0 -9
  69. package/dist/utils/keyhive.d.ts.map +0 -1
  70. package/dist/utils/keyhive.js +0 -26
  71. package/dist/utils/keyhive.js.map +0 -1
@@ -1,572 +1,565 @@
1
- import { AutomergeUrl, Repo, UrlHeads } from "@automerge/automerge-repo";
2
- import * as A from "@automerge/automerge";
1
+ import {AutomergeUrl, Repo, UrlHeads} from "@automerge/automerge-repo"
2
+ import * as A from "@automerge/automerge"
3
3
  import {
4
- ChangeType,
5
- FileType,
6
- SyncSnapshot,
7
- FileDocument,
8
- DirectoryDocument,
9
- DetectedChange,
10
- } from "../types";
4
+ ChangeType,
5
+ FileType,
6
+ SyncSnapshot,
7
+ FileDocument,
8
+ DirectoryDocument,
9
+ DetectedChange,
10
+ } from "../types"
11
11
  import {
12
- readFileContent,
13
- listDirectory,
14
- getRelativePath,
15
- findFileInDirectoryHierarchy,
16
- joinAndNormalizePath,
17
- getPlainUrl,
18
- } from "../utils";
19
- import { isContentEqual } from "../utils/content";
20
- import { out } from "../utils/output";
12
+ readFileContent,
13
+ listDirectory,
14
+ getRelativePath,
15
+ findFileInDirectoryHierarchy,
16
+ joinAndNormalizePath,
17
+ getPlainUrl,
18
+ readDocContent,
19
+ } from "../utils"
20
+ import {isContentEqual} from "../utils/content"
21
+ import {out} from "../utils/output"
21
22
 
22
23
  /**
23
24
  * Change detection engine
24
25
  */
25
26
  export class ChangeDetector {
26
- constructor(
27
- private repo: Repo,
28
- private rootPath: string,
29
- private excludePatterns: string[] = []
30
- ) {}
31
-
32
- /**
33
- * Detect all changes between local filesystem and snapshot
34
- */
35
- async detectChanges(snapshot: SyncSnapshot): Promise<DetectedChange[]> {
36
- const changes: DetectedChange[] = [];
37
-
38
- // Get current filesystem state
39
- const currentFiles = await this.getCurrentFilesystemState();
40
-
41
- // Check for local changes (new, modified, deleted files)
42
- const localChanges = await this.detectLocalChanges(snapshot, currentFiles);
43
- changes.push(...localChanges);
44
-
45
- // Check for remote changes (changes in Automerge documents)
46
- const remoteChanges = await this.detectRemoteChanges(snapshot);
47
- changes.push(...remoteChanges);
48
-
49
- // Check for new remote documents not in snapshot (critical for clone scenarios)
50
- const newRemoteDocuments = await this.detectNewRemoteDocuments(snapshot);
51
- changes.push(...newRemoteDocuments);
52
-
53
- return changes;
54
- }
55
-
56
- /**
57
- * Detect changes in local filesystem compared to snapshot
58
- */
59
- private async detectLocalChanges(
60
- snapshot: SyncSnapshot,
61
- currentFiles: Map<string, { content: string | Uint8Array; type: FileType }>
62
- ): Promise<DetectedChange[]> {
63
- const changes: DetectedChange[] = [];
64
-
65
- // Check for new and modified files in parallel for better performance
66
- await Promise.all(
67
- Array.from(currentFiles.entries()).map(
68
- async ([relativePath, fileInfo]) => {
69
- const snapshotEntry = snapshot.files.get(relativePath);
70
-
71
- if (!snapshotEntry) {
72
- // New file
73
- changes.push({
74
- path: relativePath,
75
- changeType: ChangeType.LOCAL_ONLY,
76
- fileType: fileInfo.type,
77
- localContent: fileInfo.content,
78
- remoteContent: null,
79
- });
80
- } else {
81
- // Check if content changed
82
- const lastKnownContent = await this.getContentAtHead(
83
- snapshotEntry.url,
84
- snapshotEntry.head
85
- );
86
-
87
- const contentChanged = !isContentEqual(
88
- fileInfo.content,
89
- lastKnownContent
90
- );
91
-
92
- if (contentChanged) {
93
- // Check remote state too
94
- const currentRemoteContent = await this.getCurrentRemoteContent(
95
- snapshotEntry.url
96
- );
97
-
98
- const remoteChanged = !isContentEqual(
99
- lastKnownContent,
100
- currentRemoteContent
101
- );
102
-
103
- const changeType = remoteChanged
104
- ? ChangeType.BOTH_CHANGED
105
- : ChangeType.LOCAL_ONLY;
106
-
107
- const remoteHead = await this.getCurrentRemoteHead(
108
- snapshotEntry.url
109
- );
110
-
111
- changes.push({
112
- path: relativePath,
113
- changeType,
114
- fileType: fileInfo.type,
115
- localContent: fileInfo.content,
116
- remoteContent: currentRemoteContent,
117
- localHead: snapshotEntry.head,
118
- remoteHead,
119
- });
120
- }
121
- }
122
- }
123
- )
124
- );
125
-
126
- // Check for deleted files in parallel
127
- await Promise.all(
128
- Array.from(snapshot.files.entries())
129
- .filter(([relativePath]) => !currentFiles.has(relativePath))
130
- .map(async ([relativePath, snapshotEntry]) => {
131
- // File was deleted locally
132
- const currentRemoteContent = await this.getCurrentRemoteContent(
133
- snapshotEntry.url
134
- );
135
- const lastKnownContent = await this.getContentAtHead(
136
- snapshotEntry.url,
137
- snapshotEntry.head
138
- );
139
-
140
- const remoteChanged = !isContentEqual(
141
- lastKnownContent,
142
- currentRemoteContent
143
- );
144
-
145
- const changeType = remoteChanged
146
- ? ChangeType.BOTH_CHANGED
147
- : ChangeType.LOCAL_ONLY;
148
-
149
- changes.push({
150
- path: relativePath,
151
- changeType,
152
- fileType: FileType.TEXT, // Will be determined from document
153
- localContent: null,
154
- remoteContent: currentRemoteContent,
155
- localHead: snapshotEntry.head,
156
- remoteHead: await this.getCurrentRemoteHead(snapshotEntry.url),
157
- });
158
- })
159
- );
160
-
161
- return changes;
162
- }
163
-
164
- /**
165
- * Detect changes in remote Automerge documents compared to snapshot
166
- */
167
- private async detectRemoteChanges(
168
- snapshot: SyncSnapshot
169
- ): Promise<DetectedChange[]> {
170
- const changes: DetectedChange[] = [];
171
-
172
- await Promise.all(
173
- Array.from(snapshot.files.entries()).map(
174
- async ([relativePath, snapshotEntry]) => {
175
- // Check if file still exists in remote directory listing
176
- const stillExistsInDirectory = await this.fileExistsInRemoteDirectory(
177
- snapshot.rootDirectoryUrl,
178
- relativePath
179
- );
180
-
181
- if (!stillExistsInDirectory) {
182
- // File was removed from remote directory listing
183
- const localContent = await this.getLocalContent(relativePath);
184
-
185
- // Only report as deleted if local file still exists
186
- // (if local file is also deleted, detectLocalChanges handles it)
187
- if (localContent !== null) {
188
- changes.push({
189
- path: relativePath,
190
- changeType: ChangeType.REMOTE_ONLY,
191
- fileType: FileType.TEXT,
192
- localContent,
193
- remoteContent: null, // File deleted remotely
194
- localHead: snapshotEntry.head,
195
- remoteHead: snapshotEntry.head,
196
- });
197
- }
198
- return;
199
- }
200
-
201
- const currentRemoteHead = await this.getCurrentRemoteHead(
202
- snapshotEntry.url
203
- );
204
-
205
- if (!A.equals(currentRemoteHead, snapshotEntry.head)) {
206
- // Remote document has changed
207
- const currentRemoteContent = await this.getCurrentRemoteContent(
208
- snapshotEntry.url
209
- );
210
- const localContent = await this.getLocalContent(relativePath);
211
- const lastKnownContent = await this.getContentAtHead(
212
- snapshotEntry.url,
213
- snapshotEntry.head
214
- );
215
-
216
- const localChanged = localContent
217
- ? !isContentEqual(localContent, lastKnownContent)
218
- : false;
219
-
220
- const changeType = localChanged
221
- ? ChangeType.BOTH_CHANGED
222
- : ChangeType.REMOTE_ONLY;
223
-
224
- changes.push({
225
- path: relativePath,
226
- changeType,
227
- fileType: await this.getFileTypeFromContent(currentRemoteContent),
228
- localContent,
229
- remoteContent: currentRemoteContent,
230
- localHead: snapshotEntry.head,
231
- remoteHead: currentRemoteHead,
232
- });
233
- }
234
- }
235
- )
236
- );
237
-
238
- return changes;
239
- }
240
-
241
- /**
242
- * Detect new remote documents from directory hierarchy that aren't in snapshot
243
- * This is critical for clone scenarios where local snapshot is empty
244
- */
245
- private async detectNewRemoteDocuments(
246
- snapshot: SyncSnapshot
247
- ): Promise<DetectedChange[]> {
248
- const changes: DetectedChange[] = [];
249
-
250
- // If no root directory URL, nothing to discover
251
- if (!snapshot.rootDirectoryUrl) {
252
- return changes;
253
- }
254
-
255
- try {
256
- // Recursively traverse the directory hierarchy
257
- await this.discoverRemoteDocumentsRecursive(
258
- snapshot.rootDirectoryUrl,
259
- "",
260
- snapshot,
261
- changes
262
- );
263
- } catch (error) {
264
- out.taskLine(`Failed to discover remote documents: ${error}`, true);
265
- }
266
-
267
- return changes;
268
- }
269
-
270
- /**
271
- * Recursively discover remote documents in directory hierarchy
272
- */
273
- private async discoverRemoteDocumentsRecursive(
274
- directoryUrl: AutomergeUrl,
275
- currentPath: string,
276
- snapshot: SyncSnapshot,
277
- changes: DetectedChange[]
278
- ): Promise<void> {
279
- try {
280
- // Strip heads for current document state
281
- const plainUrl = getPlainUrl(directoryUrl);
282
- const dirHandle = await this.repo.find<DirectoryDocument>(plainUrl);
283
-
284
- // Wait for document to be available (important during clone when fetching from network)
285
- const dirDoc = await this.waitForDocument<DirectoryDocument>(dirHandle);
286
-
287
- if (!dirDoc) {
288
- return;
289
- }
290
-
291
- // Process each entry in the directory
292
- for (const entry of dirDoc.docs) {
293
- const entryPath = currentPath
294
- ? `${currentPath}/${entry.name}`
295
- : entry.name;
296
-
297
- if (entry.type === "file") {
298
- // Check if this file is already tracked in the snapshot
299
- const existingEntry = snapshot.files.get(entryPath);
300
-
301
- if (!existingEntry) {
302
- // This is a remote file not in our snapshot
303
- const localContent = await this.getLocalContent(entryPath);
304
- const remoteContent = await this.getCurrentRemoteContent(entry.url);
305
- const remoteHead = await this.getCurrentRemoteHead(entry.url);
306
-
307
- if (localContent && remoteContent) {
308
- // File exists both locally and remotely but not in snapshot
309
- changes.push({
310
- path: entryPath,
311
- changeType: ChangeType.BOTH_CHANGED,
312
- fileType: await this.getFileTypeFromContent(remoteContent),
313
- localContent,
314
- remoteContent,
315
- remoteHead,
316
- });
317
- } else if (localContent !== null && remoteContent === null) {
318
- // File exists locally but not remotely (shouldn't happen in this flow)
319
- changes.push({
320
- path: entryPath,
321
- changeType: ChangeType.LOCAL_ONLY,
322
- fileType: await this.getFileTypeFromContent(localContent),
323
- localContent,
324
- remoteContent: null,
325
- });
326
- } else if (localContent === null && remoteContent !== null) {
327
- // File exists remotely but not locally - this is what we need for clone!
328
- changes.push({
329
- path: entryPath,
330
- changeType: ChangeType.REMOTE_ONLY,
331
- fileType: await this.getFileTypeFromContent(remoteContent),
332
- localContent: null,
333
- remoteContent,
334
- remoteHead,
335
- });
336
- }
337
- // Only ignore if neither local nor remote content exists (ghost entry)
338
- }
339
- } else if (entry.type === "folder") {
340
- // Recursively process subdirectory
341
- await this.discoverRemoteDocumentsRecursive(
342
- entry.url,
343
- entryPath,
344
- snapshot,
345
- changes
346
- );
347
- }
348
- }
349
- } catch (error) {
350
- out.taskLine(`Failed to process directory: ${error}`, true);
351
- }
352
- }
353
-
354
- /**
355
- * Get current filesystem state as a map
356
- */
357
- private async getCurrentFilesystemState(): Promise<
358
- Map<string, { content: string | Uint8Array; type: FileType }>
359
- > {
360
- const fileMap = new Map<
361
- string,
362
- { content: string | Uint8Array; type: FileType }
363
- >();
364
-
365
- try {
366
- const entries = await listDirectory(
367
- this.rootPath,
368
- true,
369
- this.excludePatterns
370
- );
371
-
372
- const fileEntries = entries.filter(
373
- (entry) => entry.type !== FileType.DIRECTORY
374
- );
375
-
376
- await Promise.all(
377
- fileEntries.map(async (entry) => {
378
- const relativePath = getRelativePath(this.rootPath, entry.path);
379
- const content = await readFileContent(entry.path);
380
-
381
- fileMap.set(relativePath, {
382
- content,
383
- type: entry.type,
384
- });
385
- })
386
- );
387
- } catch (error) {
388
- out.taskLine(`Failed to scan filesystem: ${error}`, true);
389
- // Log more details about the error
390
- if (error instanceof Error) {
391
- out.taskLine(`Error details: ${error.message}`, true);
392
- if (error.stack) {
393
- out.taskLine(`Stack: ${error.stack}`, true);
394
- }
395
- }
396
- }
397
-
398
- return fileMap;
399
- }
400
-
401
- /**
402
- * Get local file content if it exists
403
- */
404
- private async getLocalContent(
405
- relativePath: string
406
- ): Promise<string | Uint8Array | null> {
407
- try {
408
- const fullPath = joinAndNormalizePath(this.rootPath, relativePath);
409
- return await readFileContent(fullPath);
410
- } catch {
411
- return null;
412
- }
413
- }
414
-
415
- /**
416
- * Get content from Automerge document at specific head
417
- */
418
- private async getContentAtHead(
419
- url: AutomergeUrl,
420
- heads: UrlHeads
421
- ): Promise<string | Uint8Array | null> {
422
- // Strip heads for current document state
423
- const plainUrl = getPlainUrl(url);
424
- const handle = await this.repo.find<FileDocument>(plainUrl);
425
- const doc = await handle.view(heads).doc();
426
-
427
- const content = (doc as FileDocument | undefined)?.content;
428
- // Convert ImmutableString to regular string
429
- if (A.isImmutableString(content)) {
430
- return content.toString();
431
- }
432
- return content as string | Uint8Array;
433
- }
434
-
435
- /**
436
- * Get current content from Automerge document
437
- */
438
- private async getCurrentRemoteContent(
439
- url: AutomergeUrl
440
- ): Promise<string | Uint8Array | null> {
441
- try {
442
- // Strip heads for current document state
443
- const plainUrl = getPlainUrl(url);
444
- const handle = await this.repo.find<FileDocument>(plainUrl);
445
-
446
- // Wait for document to be available (important during clone)
447
- const doc = await this.waitForDocument<FileDocument>(handle);
448
-
449
- if (!doc) return null;
450
-
451
- const fileDoc = doc;
452
- const content = fileDoc.content;
453
- // Convert ImmutableString to regular string
454
- if (A.isImmutableString(content)) {
455
- return content.toString();
456
- }
457
- return content as string | Uint8Array;
458
- } catch (error) {
459
- out.taskLine(`Failed to get remote content: ${error}`, true);
460
- return null;
461
- }
462
- }
463
-
464
- /**
465
- * Wait for a document to be available, with retry logic.
466
- * This is important during clone when documents are being fetched from the network.
467
- */
468
- private async waitForDocument<T>(
469
- handle: { doc: () => Promise<T | undefined> },
470
- options: { maxRetries?: number; retryDelayMs?: number } = {}
471
- ): Promise<T | undefined> {
472
- const { maxRetries = 5, retryDelayMs = 100 } = options;
473
-
474
- for (let attempt = 0; attempt < maxRetries; attempt++) {
475
- const doc = await handle.doc();
476
- if (doc !== undefined) {
477
- return doc;
478
- }
479
-
480
- // Wait before retrying
481
- if (attempt < maxRetries - 1) {
482
- await new Promise((r) => setTimeout(r, retryDelayMs));
483
- }
484
- }
485
-
486
- // Return undefined if document never became available
487
- return undefined;
488
- }
489
-
490
- /**
491
- * Get current head of Automerge document
492
- */
493
- private async getCurrentRemoteHead(url: AutomergeUrl): Promise<UrlHeads> {
494
- // Strip heads for current document state
495
- const plainUrl = getPlainUrl(url);
496
- const handle = await this.repo.find<FileDocument>(plainUrl);
497
- return handle.heads();
498
- }
499
-
500
- /**
501
- * Determine file type from content
502
- */
503
- private async getFileTypeFromContent(
504
- content: string | Uint8Array | null
505
- ): Promise<FileType> {
506
- if (!content) return FileType.TEXT;
507
-
508
- if (content instanceof Uint8Array) {
509
- return FileType.BINARY;
510
- } else {
511
- return FileType.TEXT;
512
- }
513
- }
514
-
515
- /**
516
- * Classify change type for a path
517
- */
518
- async classifyChange(
519
- relativePath: string,
520
- snapshot: SyncSnapshot
521
- ): Promise<ChangeType> {
522
- const snapshotEntry = snapshot.files.get(relativePath);
523
- const localContent = await this.getLocalContent(relativePath);
524
-
525
- if (!snapshotEntry) {
526
- // New file
527
- return ChangeType.LOCAL_ONLY;
528
- }
529
-
530
- const lastKnownContent = await this.getContentAtHead(
531
- snapshotEntry.url,
532
- snapshotEntry.head
533
- );
534
- const currentRemoteContent = await this.getCurrentRemoteContent(
535
- snapshotEntry.url
536
- );
537
-
538
- const localChanged = localContent
539
- ? !isContentEqual(localContent, lastKnownContent)
540
- : true;
541
- const remoteChanged = !isContentEqual(
542
- lastKnownContent,
543
- currentRemoteContent
544
- );
545
-
546
- if (!localChanged && !remoteChanged) {
547
- return ChangeType.NO_CHANGE;
548
- } else if (localChanged && !remoteChanged) {
549
- return ChangeType.LOCAL_ONLY;
550
- } else if (!localChanged && remoteChanged) {
551
- return ChangeType.REMOTE_ONLY;
552
- } else {
553
- return ChangeType.BOTH_CHANGED;
554
- }
555
- }
556
-
557
- /**
558
- * Check if a file exists in the remote directory hierarchy
559
- */
560
- private async fileExistsInRemoteDirectory(
561
- rootDirectoryUrl: AutomergeUrl | undefined,
562
- filePath: string
563
- ): Promise<boolean> {
564
- if (!rootDirectoryUrl) return false;
565
- const entry = await findFileInDirectoryHierarchy(
566
- this.repo,
567
- rootDirectoryUrl,
568
- filePath
569
- );
570
- return entry !== null;
571
- }
27
+ constructor(
28
+ private repo: Repo,
29
+ private rootPath: string,
30
+ private excludePatterns: string[] = []
31
+ ) {}
32
+
33
+ /**
34
+ * Detect all changes between local filesystem and snapshot
35
+ */
36
+ async detectChanges(snapshot: SyncSnapshot): Promise<DetectedChange[]> {
37
+ const changes: DetectedChange[] = []
38
+
39
+ // Get current filesystem state
40
+ const currentFiles = await this.getCurrentFilesystemState()
41
+
42
+ // Check for local changes (new, modified, deleted files)
43
+ const localChanges = await this.detectLocalChanges(snapshot, currentFiles)
44
+ changes.push(...localChanges)
45
+
46
+ // Check for remote changes (changes in Automerge documents)
47
+ const remoteChanges = await this.detectRemoteChanges(snapshot)
48
+ changes.push(...remoteChanges)
49
+
50
+ // Check for new remote documents not in snapshot (critical for clone scenarios)
51
+ const newRemoteDocuments = await this.detectNewRemoteDocuments(snapshot)
52
+ changes.push(...newRemoteDocuments)
53
+
54
+ return changes
55
+ }
56
+
57
+ /**
58
+ * Detect changes in local filesystem compared to snapshot
59
+ */
60
+ private async detectLocalChanges(
61
+ snapshot: SyncSnapshot,
62
+ currentFiles: Map<string, {content: string | Uint8Array; type: FileType}>
63
+ ): Promise<DetectedChange[]> {
64
+ const changes: DetectedChange[] = []
65
+
66
+ // Check for new and modified files in parallel for better performance
67
+ await Promise.all(
68
+ Array.from(currentFiles.entries()).map(
69
+ async ([relativePath, fileInfo]) => {
70
+ const snapshotEntry = snapshot.files.get(relativePath)
71
+
72
+ if (!snapshotEntry) {
73
+ // New file
74
+ changes.push({
75
+ path: relativePath,
76
+ changeType: ChangeType.LOCAL_ONLY,
77
+ fileType: fileInfo.type,
78
+ localContent: fileInfo.content,
79
+ remoteContent: null,
80
+ })
81
+ } else {
82
+ // Check if content changed
83
+ const lastKnownContent = await this.getContentAtHead(
84
+ snapshotEntry.url,
85
+ snapshotEntry.head
86
+ )
87
+
88
+ const contentChanged = !isContentEqual(
89
+ fileInfo.content,
90
+ lastKnownContent
91
+ )
92
+
93
+ if (contentChanged) {
94
+ // Check remote state too
95
+ const currentRemoteContent = await this.getCurrentRemoteContent(
96
+ snapshotEntry.url
97
+ )
98
+
99
+ const remoteChanged = !isContentEqual(
100
+ lastKnownContent,
101
+ currentRemoteContent
102
+ )
103
+
104
+ const changeType = remoteChanged
105
+ ? ChangeType.BOTH_CHANGED
106
+ : ChangeType.LOCAL_ONLY
107
+
108
+ const remoteHead = await this.getCurrentRemoteHead(
109
+ snapshotEntry.url
110
+ )
111
+
112
+ changes.push({
113
+ path: relativePath,
114
+ changeType,
115
+ fileType: fileInfo.type,
116
+ localContent: fileInfo.content,
117
+ remoteContent: currentRemoteContent,
118
+ localHead: snapshotEntry.head,
119
+ remoteHead,
120
+ })
121
+ }
122
+ }
123
+ }
124
+ )
125
+ )
126
+
127
+ // Check for deleted files in parallel
128
+ await Promise.all(
129
+ Array.from(snapshot.files.entries())
130
+ .filter(([relativePath]) => !currentFiles.has(relativePath))
131
+ .map(async ([relativePath, snapshotEntry]) => {
132
+ // File was deleted locally
133
+ const currentRemoteContent = await this.getCurrentRemoteContent(
134
+ snapshotEntry.url
135
+ )
136
+ const lastKnownContent = await this.getContentAtHead(
137
+ snapshotEntry.url,
138
+ snapshotEntry.head
139
+ )
140
+
141
+ const remoteChanged = !isContentEqual(
142
+ lastKnownContent,
143
+ currentRemoteContent
144
+ )
145
+
146
+ const changeType = remoteChanged
147
+ ? ChangeType.BOTH_CHANGED
148
+ : ChangeType.LOCAL_ONLY
149
+
150
+ changes.push({
151
+ path: relativePath,
152
+ changeType,
153
+ fileType: FileType.TEXT, // Will be determined from document
154
+ localContent: null,
155
+ remoteContent: currentRemoteContent,
156
+ localHead: snapshotEntry.head,
157
+ remoteHead: await this.getCurrentRemoteHead(snapshotEntry.url),
158
+ })
159
+ })
160
+ )
161
+
162
+ return changes
163
+ }
164
+
165
+ /**
166
+ * Detect changes in remote Automerge documents compared to snapshot
167
+ */
168
+ private async detectRemoteChanges(
169
+ snapshot: SyncSnapshot
170
+ ): Promise<DetectedChange[]> {
171
+ const changes: DetectedChange[] = []
172
+
173
+ await Promise.all(
174
+ Array.from(snapshot.files.entries()).map(
175
+ async ([relativePath, snapshotEntry]) => {
176
+ // Check if file still exists in remote directory listing
177
+ const stillExistsInDirectory = await this.fileExistsInRemoteDirectory(
178
+ snapshot.rootDirectoryUrl,
179
+ relativePath
180
+ )
181
+
182
+ if (!stillExistsInDirectory) {
183
+ // File was removed from remote directory listing
184
+ const localContent = await this.getLocalContent(relativePath)
185
+
186
+ // Only report as deleted if local file still exists
187
+ // (if local file is also deleted, detectLocalChanges handles it)
188
+ if (localContent !== null) {
189
+ changes.push({
190
+ path: relativePath,
191
+ changeType: ChangeType.REMOTE_ONLY,
192
+ fileType: FileType.TEXT,
193
+ localContent,
194
+ remoteContent: null, // File deleted remotely
195
+ localHead: snapshotEntry.head,
196
+ remoteHead: snapshotEntry.head,
197
+ })
198
+ }
199
+ return
200
+ }
201
+
202
+ const currentRemoteHead = await this.getCurrentRemoteHead(
203
+ snapshotEntry.url
204
+ )
205
+
206
+ if (!A.equals(currentRemoteHead, snapshotEntry.head)) {
207
+ // Remote document has changed
208
+ const currentRemoteContent = await this.getCurrentRemoteContent(
209
+ snapshotEntry.url
210
+ )
211
+ const localContent = await this.getLocalContent(relativePath)
212
+ const lastKnownContent = await this.getContentAtHead(
213
+ snapshotEntry.url,
214
+ snapshotEntry.head
215
+ )
216
+
217
+ const localChanged = localContent
218
+ ? !isContentEqual(localContent, lastKnownContent)
219
+ : false
220
+
221
+ const changeType = localChanged
222
+ ? ChangeType.BOTH_CHANGED
223
+ : ChangeType.REMOTE_ONLY
224
+
225
+ changes.push({
226
+ path: relativePath,
227
+ changeType,
228
+ fileType: await this.getFileTypeFromContent(currentRemoteContent),
229
+ localContent,
230
+ remoteContent: currentRemoteContent,
231
+ localHead: snapshotEntry.head,
232
+ remoteHead: currentRemoteHead,
233
+ })
234
+ }
235
+ }
236
+ )
237
+ )
238
+
239
+ return changes
240
+ }
241
+
242
+ /**
243
+ * Detect new remote documents from directory hierarchy that aren't in snapshot
244
+ * This is critical for clone scenarios where local snapshot is empty
245
+ */
246
+ private async detectNewRemoteDocuments(
247
+ snapshot: SyncSnapshot
248
+ ): Promise<DetectedChange[]> {
249
+ const changes: DetectedChange[] = []
250
+
251
+ // If no root directory URL, nothing to discover
252
+ if (!snapshot.rootDirectoryUrl) {
253
+ return changes
254
+ }
255
+
256
+ try {
257
+ // Recursively traverse the directory hierarchy
258
+ await this.discoverRemoteDocumentsRecursive(
259
+ snapshot.rootDirectoryUrl,
260
+ "",
261
+ snapshot,
262
+ changes
263
+ )
264
+ } catch (error) {
265
+ out.taskLine(`Failed to discover remote documents: ${error}`, true)
266
+ }
267
+
268
+ return changes
269
+ }
270
+
271
+ /**
272
+ * Recursively discover remote documents in directory hierarchy
273
+ */
274
+ private async discoverRemoteDocumentsRecursive(
275
+ directoryUrl: AutomergeUrl,
276
+ currentPath: string,
277
+ snapshot: SyncSnapshot,
278
+ changes: DetectedChange[]
279
+ ): Promise<void> {
280
+ try {
281
+ // Strip heads for current document state
282
+ const plainUrl = getPlainUrl(directoryUrl)
283
+ const dirHandle = await this.repo.find<DirectoryDocument>(plainUrl)
284
+
285
+ // Wait for document to be available (important during clone when fetching from network)
286
+ const dirDoc = await this.waitForDocument<DirectoryDocument>(dirHandle)
287
+
288
+ if (!dirDoc) {
289
+ return
290
+ }
291
+
292
+ // Process each entry in the directory
293
+ for (const entry of dirDoc.docs) {
294
+ const entryPath = currentPath
295
+ ? `${currentPath}/${entry.name}`
296
+ : entry.name
297
+
298
+ if (entry.type === "file") {
299
+ // Check if this file is already tracked in the snapshot
300
+ const existingEntry = snapshot.files.get(entryPath)
301
+
302
+ if (!existingEntry) {
303
+ // This is a remote file not in our snapshot
304
+ const localContent = await this.getLocalContent(entryPath)
305
+ const remoteContent = await this.getCurrentRemoteContent(entry.url)
306
+ const remoteHead = await this.getCurrentRemoteHead(entry.url)
307
+
308
+ if (localContent && remoteContent) {
309
+ // File exists both locally and remotely but not in snapshot
310
+ changes.push({
311
+ path: entryPath,
312
+ changeType: ChangeType.BOTH_CHANGED,
313
+ fileType: await this.getFileTypeFromContent(remoteContent),
314
+ localContent,
315
+ remoteContent,
316
+ remoteHead,
317
+ })
318
+ } else if (localContent !== null && remoteContent === null) {
319
+ // File exists locally but not remotely (shouldn't happen in this flow)
320
+ changes.push({
321
+ path: entryPath,
322
+ changeType: ChangeType.LOCAL_ONLY,
323
+ fileType: await this.getFileTypeFromContent(localContent),
324
+ localContent,
325
+ remoteContent: null,
326
+ })
327
+ } else if (localContent === null && remoteContent !== null) {
328
+ // File exists remotely but not locally - this is what we need for clone!
329
+ changes.push({
330
+ path: entryPath,
331
+ changeType: ChangeType.REMOTE_ONLY,
332
+ fileType: await this.getFileTypeFromContent(remoteContent),
333
+ localContent: null,
334
+ remoteContent,
335
+ remoteHead,
336
+ })
337
+ }
338
+ // Only ignore if neither local nor remote content exists (ghost entry)
339
+ }
340
+ } else if (entry.type === "folder") {
341
+ // Recursively process subdirectory
342
+ await this.discoverRemoteDocumentsRecursive(
343
+ entry.url,
344
+ entryPath,
345
+ snapshot,
346
+ changes
347
+ )
348
+ }
349
+ }
350
+ } catch (error) {
351
+ out.taskLine(`Failed to process directory: ${error}`, true)
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Get current filesystem state as a map
357
+ */
358
+ private async getCurrentFilesystemState(): Promise<
359
+ Map<string, {content: string | Uint8Array; type: FileType}>
360
+ > {
361
+ const fileMap = new Map<
362
+ string,
363
+ {content: string | Uint8Array; type: FileType}
364
+ >()
365
+
366
+ try {
367
+ const entries = await listDirectory(
368
+ this.rootPath,
369
+ true,
370
+ this.excludePatterns
371
+ )
372
+
373
+ const fileEntries = entries.filter(
374
+ entry => entry.type !== FileType.DIRECTORY
375
+ )
376
+
377
+ await Promise.all(
378
+ fileEntries.map(async entry => {
379
+ const relativePath = getRelativePath(this.rootPath, entry.path)
380
+ const content = await readFileContent(entry.path)
381
+
382
+ fileMap.set(relativePath, {
383
+ content,
384
+ type: entry.type,
385
+ })
386
+ })
387
+ )
388
+ } catch (error) {
389
+ out.taskLine(`Failed to scan filesystem: ${error}`, true)
390
+ // Log more details about the error
391
+ if (error instanceof Error) {
392
+ out.taskLine(`Error details: ${error.message}`, true)
393
+ if (error.stack) {
394
+ out.taskLine(`Stack: ${error.stack}`, true)
395
+ }
396
+ }
397
+ }
398
+
399
+ return fileMap
400
+ }
401
+
402
+ /**
403
+ * Get local file content if it exists
404
+ */
405
+ private async getLocalContent(
406
+ relativePath: string
407
+ ): Promise<string | Uint8Array | null> {
408
+ try {
409
+ const fullPath = joinAndNormalizePath(this.rootPath, relativePath)
410
+ return await readFileContent(fullPath)
411
+ } catch {
412
+ return null
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Get content from Automerge document at specific head
418
+ */
419
+ private async getContentAtHead(
420
+ url: AutomergeUrl,
421
+ heads: UrlHeads
422
+ ): Promise<string | Uint8Array | null> {
423
+ // Strip heads for current document state
424
+ const plainUrl = getPlainUrl(url)
425
+ const handle = await this.repo.find<FileDocument>(plainUrl)
426
+ const doc = await handle.view(heads).doc()
427
+
428
+ const content = (doc as FileDocument | undefined)?.content
429
+ return readDocContent(content)
430
+ }
431
+
432
+ /**
433
+ * Get current content from Automerge document
434
+ */
435
+ private async getCurrentRemoteContent(
436
+ url: AutomergeUrl
437
+ ): Promise<string | Uint8Array | null> {
438
+ try {
439
+ // Strip heads for current document state
440
+ const plainUrl = getPlainUrl(url)
441
+ const handle = await this.repo.find<FileDocument>(plainUrl)
442
+
443
+ // Wait for document to be available (important during clone)
444
+ const doc = await this.waitForDocument<FileDocument>(handle)
445
+
446
+ if (!doc) return null
447
+
448
+ const fileDoc = doc
449
+ const content = fileDoc.content
450
+ return readDocContent(content)
451
+ } catch (error) {
452
+ out.taskLine(`Failed to get remote content: ${error}`, true)
453
+ return null
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Wait for a document to be available, with retry logic.
459
+ * This is important during clone when documents are being fetched from the network.
460
+ */
461
+ private async waitForDocument<T>(
462
+ handle: {doc: () => Promise<T | undefined>},
463
+ options: {maxRetries?: number; retryDelayMs?: number} = {}
464
+ ): Promise<T | undefined> {
465
+ const {maxRetries = 5, retryDelayMs = 100} = options
466
+
467
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
468
+ const doc = await handle.doc()
469
+ if (doc !== undefined) {
470
+ return doc
471
+ }
472
+
473
+ // Wait before retrying
474
+ if (attempt < maxRetries - 1) {
475
+ await new Promise(r => setTimeout(r, retryDelayMs))
476
+ }
477
+ }
478
+
479
+ // Return undefined if document never became available
480
+ return undefined
481
+ }
482
+
483
+ /**
484
+ * Get current head of Automerge document
485
+ */
486
+ private async getCurrentRemoteHead(url: AutomergeUrl): Promise<UrlHeads> {
487
+ // Strip heads for current document state
488
+ const plainUrl = getPlainUrl(url)
489
+ const handle = await this.repo.find<FileDocument>(plainUrl)
490
+ return handle.heads()
491
+ }
492
+
493
+ /**
494
+ * Determine file type from content
495
+ */
496
+ private async getFileTypeFromContent(
497
+ content: string | Uint8Array | null
498
+ ): Promise<FileType> {
499
+ if (!content) return FileType.TEXT
500
+
501
+ if (content instanceof Uint8Array) {
502
+ return FileType.BINARY
503
+ } else {
504
+ return FileType.TEXT
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Classify change type for a path
510
+ */
511
+ async classifyChange(
512
+ relativePath: string,
513
+ snapshot: SyncSnapshot
514
+ ): Promise<ChangeType> {
515
+ const snapshotEntry = snapshot.files.get(relativePath)
516
+ const localContent = await this.getLocalContent(relativePath)
517
+
518
+ if (!snapshotEntry) {
519
+ // New file
520
+ return ChangeType.LOCAL_ONLY
521
+ }
522
+
523
+ const lastKnownContent = await this.getContentAtHead(
524
+ snapshotEntry.url,
525
+ snapshotEntry.head
526
+ )
527
+ const currentRemoteContent = await this.getCurrentRemoteContent(
528
+ snapshotEntry.url
529
+ )
530
+
531
+ const localChanged = localContent
532
+ ? !isContentEqual(localContent, lastKnownContent)
533
+ : true
534
+ const remoteChanged = !isContentEqual(
535
+ lastKnownContent,
536
+ currentRemoteContent
537
+ )
538
+
539
+ if (!localChanged && !remoteChanged) {
540
+ return ChangeType.NO_CHANGE
541
+ } else if (localChanged && !remoteChanged) {
542
+ return ChangeType.LOCAL_ONLY
543
+ } else if (!localChanged && remoteChanged) {
544
+ return ChangeType.REMOTE_ONLY
545
+ } else {
546
+ return ChangeType.BOTH_CHANGED
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Check if a file exists in the remote directory hierarchy
552
+ */
553
+ private async fileExistsInRemoteDirectory(
554
+ rootDirectoryUrl: AutomergeUrl | undefined,
555
+ filePath: string
556
+ ): Promise<boolean> {
557
+ if (!rootDirectoryUrl) return false
558
+ const entry = await findFileInDirectoryHierarchy(
559
+ this.repo,
560
+ rootDirectoryUrl,
561
+ filePath
562
+ )
563
+ return entry !== null
564
+ }
572
565
  }