pushwork 1.0.0

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 (184) hide show
  1. package/README.md +460 -0
  2. package/dist/browser/browser-sync-engine.d.ts +64 -0
  3. package/dist/browser/browser-sync-engine.d.ts.map +1 -0
  4. package/dist/browser/browser-sync-engine.js +303 -0
  5. package/dist/browser/browser-sync-engine.js.map +1 -0
  6. package/dist/browser/filesystem-adapter.d.ts +84 -0
  7. package/dist/browser/filesystem-adapter.d.ts.map +1 -0
  8. package/dist/browser/filesystem-adapter.js +413 -0
  9. package/dist/browser/filesystem-adapter.js.map +1 -0
  10. package/dist/browser/index.d.ts +36 -0
  11. package/dist/browser/index.d.ts.map +1 -0
  12. package/dist/browser/index.js +90 -0
  13. package/dist/browser/index.js.map +1 -0
  14. package/dist/browser/types.d.ts +70 -0
  15. package/dist/browser/types.d.ts.map +1 -0
  16. package/dist/browser/types.js +6 -0
  17. package/dist/browser/types.js.map +1 -0
  18. package/dist/cli/commands.d.ts +71 -0
  19. package/dist/cli/commands.d.ts.map +1 -0
  20. package/dist/cli/commands.js +794 -0
  21. package/dist/cli/commands.js.map +1 -0
  22. package/dist/cli/index.d.ts +2 -0
  23. package/dist/cli/index.d.ts.map +1 -0
  24. package/dist/cli/index.js +19 -0
  25. package/dist/cli/index.js.map +1 -0
  26. package/dist/cli.d.ts +3 -0
  27. package/dist/cli.d.ts.map +1 -0
  28. package/dist/cli.js +199 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/config/index.d.ts +71 -0
  31. package/dist/config/index.d.ts.map +1 -0
  32. package/dist/config/index.js +314 -0
  33. package/dist/config/index.js.map +1 -0
  34. package/dist/core/change-detection.d.ts +78 -0
  35. package/dist/core/change-detection.d.ts.map +1 -0
  36. package/dist/core/change-detection.js +370 -0
  37. package/dist/core/change-detection.js.map +1 -0
  38. package/dist/core/index.d.ts +5 -0
  39. package/dist/core/index.d.ts.map +1 -0
  40. package/dist/core/index.js +22 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/core/isomorphic-snapshot.d.ts +58 -0
  43. package/dist/core/isomorphic-snapshot.d.ts.map +1 -0
  44. package/dist/core/isomorphic-snapshot.js +204 -0
  45. package/dist/core/isomorphic-snapshot.js.map +1 -0
  46. package/dist/core/move-detection.d.ts +72 -0
  47. package/dist/core/move-detection.d.ts.map +1 -0
  48. package/dist/core/move-detection.js +200 -0
  49. package/dist/core/move-detection.js.map +1 -0
  50. package/dist/core/snapshot.d.ts +109 -0
  51. package/dist/core/snapshot.d.ts.map +1 -0
  52. package/dist/core/snapshot.js +263 -0
  53. package/dist/core/snapshot.js.map +1 -0
  54. package/dist/core/sync-engine.d.ts +110 -0
  55. package/dist/core/sync-engine.d.ts.map +1 -0
  56. package/dist/core/sync-engine.js +817 -0
  57. package/dist/core/sync-engine.js.map +1 -0
  58. package/dist/index.d.ts +6 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +27 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/platform/browser-filesystem.d.ts +26 -0
  63. package/dist/platform/browser-filesystem.d.ts.map +1 -0
  64. package/dist/platform/browser-filesystem.js +91 -0
  65. package/dist/platform/browser-filesystem.js.map +1 -0
  66. package/dist/platform/filesystem.d.ts +29 -0
  67. package/dist/platform/filesystem.d.ts.map +1 -0
  68. package/dist/platform/filesystem.js +65 -0
  69. package/dist/platform/filesystem.js.map +1 -0
  70. package/dist/platform/node-filesystem.d.ts +21 -0
  71. package/dist/platform/node-filesystem.d.ts.map +1 -0
  72. package/dist/platform/node-filesystem.js +93 -0
  73. package/dist/platform/node-filesystem.js.map +1 -0
  74. package/dist/types/config.d.ts +119 -0
  75. package/dist/types/config.d.ts.map +1 -0
  76. package/dist/types/config.js +3 -0
  77. package/dist/types/config.js.map +1 -0
  78. package/dist/types/documents.d.ts +70 -0
  79. package/dist/types/documents.d.ts.map +1 -0
  80. package/dist/types/documents.js +23 -0
  81. package/dist/types/documents.js.map +1 -0
  82. package/dist/types/index.d.ts +4 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +23 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/dist/types/snapshot.d.ts +81 -0
  87. package/dist/types/snapshot.d.ts.map +1 -0
  88. package/dist/types/snapshot.js +17 -0
  89. package/dist/types/snapshot.js.map +1 -0
  90. package/dist/utils/content-similarity.d.ts +53 -0
  91. package/dist/utils/content-similarity.d.ts.map +1 -0
  92. package/dist/utils/content-similarity.js +155 -0
  93. package/dist/utils/content-similarity.js.map +1 -0
  94. package/dist/utils/content.d.ts +5 -0
  95. package/dist/utils/content.d.ts.map +1 -0
  96. package/dist/utils/content.js +30 -0
  97. package/dist/utils/content.js.map +1 -0
  98. package/dist/utils/fs-browser.d.ts +57 -0
  99. package/dist/utils/fs-browser.d.ts.map +1 -0
  100. package/dist/utils/fs-browser.js +311 -0
  101. package/dist/utils/fs-browser.js.map +1 -0
  102. package/dist/utils/fs-node.d.ts +53 -0
  103. package/dist/utils/fs-node.d.ts.map +1 -0
  104. package/dist/utils/fs-node.js +220 -0
  105. package/dist/utils/fs-node.js.map +1 -0
  106. package/dist/utils/fs.d.ts +62 -0
  107. package/dist/utils/fs.d.ts.map +1 -0
  108. package/dist/utils/fs.js +293 -0
  109. package/dist/utils/fs.js.map +1 -0
  110. package/dist/utils/index.d.ts +4 -0
  111. package/dist/utils/index.d.ts.map +1 -0
  112. package/dist/utils/index.js +23 -0
  113. package/dist/utils/index.js.map +1 -0
  114. package/dist/utils/isomorphic.d.ts +29 -0
  115. package/dist/utils/isomorphic.d.ts.map +1 -0
  116. package/dist/utils/isomorphic.js +139 -0
  117. package/dist/utils/isomorphic.js.map +1 -0
  118. package/dist/utils/mime-types.d.ts +13 -0
  119. package/dist/utils/mime-types.d.ts.map +1 -0
  120. package/dist/utils/mime-types.js +240 -0
  121. package/dist/utils/mime-types.js.map +1 -0
  122. package/dist/utils/network-sync.d.ts +12 -0
  123. package/dist/utils/network-sync.d.ts.map +1 -0
  124. package/dist/utils/network-sync.js +149 -0
  125. package/dist/utils/network-sync.js.map +1 -0
  126. package/dist/utils/pure.d.ts +25 -0
  127. package/dist/utils/pure.d.ts.map +1 -0
  128. package/dist/utils/pure.js +112 -0
  129. package/dist/utils/pure.js.map +1 -0
  130. package/dist/utils/repo-factory.d.ts +11 -0
  131. package/dist/utils/repo-factory.d.ts.map +1 -0
  132. package/dist/utils/repo-factory.js +77 -0
  133. package/dist/utils/repo-factory.js.map +1 -0
  134. package/package.json +83 -0
  135. package/src/cli/commands.ts +1053 -0
  136. package/src/cli/index.ts +2 -0
  137. package/src/cli.ts +287 -0
  138. package/src/config/index.ts +334 -0
  139. package/src/core/change-detection.ts +484 -0
  140. package/src/core/index.ts +5 -0
  141. package/src/core/move-detection.ts +269 -0
  142. package/src/core/snapshot.ts +285 -0
  143. package/src/core/sync-engine.ts +1167 -0
  144. package/src/index.ts +14 -0
  145. package/src/types/config.ts +130 -0
  146. package/src/types/documents.ts +72 -0
  147. package/src/types/index.ts +8 -0
  148. package/src/types/snapshot.ts +88 -0
  149. package/src/utils/content-similarity.ts +194 -0
  150. package/src/utils/content.ts +28 -0
  151. package/src/utils/fs.ts +289 -0
  152. package/src/utils/index.ts +8 -0
  153. package/src/utils/mime-types.ts +236 -0
  154. package/src/utils/network-sync.ts +153 -0
  155. package/src/utils/repo-factory.ts +58 -0
  156. package/test/README-TESTING-GAPS.md +174 -0
  157. package/test/integration/README.md +328 -0
  158. package/test/integration/clone-test.sh +310 -0
  159. package/test/integration/conflict-resolution-test.sh +309 -0
  160. package/test/integration/deletion-behavior-test.sh +487 -0
  161. package/test/integration/deletion-sync-test-simple.sh +193 -0
  162. package/test/integration/deletion-sync-test.sh +297 -0
  163. package/test/integration/exclude-patterns.test.ts +152 -0
  164. package/test/integration/full-integration-test.sh +363 -0
  165. package/test/integration/sync-deletion.test.ts +339 -0
  166. package/test/integration/sync-flow.test.ts +309 -0
  167. package/test/run-tests.sh +225 -0
  168. package/test/unit/content-similarity.test.ts +236 -0
  169. package/test/unit/deletion-behavior.test.ts +260 -0
  170. package/test/unit/enhanced-mime-detection.test.ts +266 -0
  171. package/test/unit/snapshot.test.ts +431 -0
  172. package/test/unit/sync-timing.test.ts +178 -0
  173. package/test/unit/utils.test.ts +368 -0
  174. package/tools/browser-sync/README.md +116 -0
  175. package/tools/browser-sync/package.json +44 -0
  176. package/tools/browser-sync/patchwork.json +1 -0
  177. package/tools/browser-sync/pnpm-lock.yaml +4202 -0
  178. package/tools/browser-sync/src/components/BrowserSyncTool.tsx +599 -0
  179. package/tools/browser-sync/src/index.ts +20 -0
  180. package/tools/browser-sync/src/polyfills.ts +31 -0
  181. package/tools/browser-sync/src/styles.css +290 -0
  182. package/tools/browser-sync/src/types.ts +27 -0
  183. package/tools/browser-sync/vite.config.ts +25 -0
  184. package/tsconfig.json +22 -0
@@ -0,0 +1,1167 @@
1
+ import {
2
+ AutomergeUrl,
3
+ Repo,
4
+ updateText,
5
+ DocHandle,
6
+ UrlHeads,
7
+ } from "@automerge/automerge-repo";
8
+ import * as A from "@automerge/automerge";
9
+ import {
10
+ SyncSnapshot,
11
+ SyncResult,
12
+ SyncError,
13
+ SyncOperation,
14
+ PendingSyncOperation,
15
+ FileDocument,
16
+ DirectoryDocument,
17
+ FileType,
18
+ ChangeType,
19
+ MoveCandidate,
20
+ } from "../types";
21
+ import {
22
+ readFileContent,
23
+ writeFileContent,
24
+ removePath,
25
+ movePath,
26
+ ensureDirectoryExists,
27
+ getFileExtension,
28
+ normalizePath,
29
+ getRelativePath,
30
+ getEnhancedMimeType,
31
+ isEnhancedTextFile,
32
+ } from "../utils";
33
+ import { isContentEqual } from "../utils/content";
34
+ import { waitForSync, getSyncServerStorageId } from "../utils/network-sync";
35
+ import { SnapshotManager } from "./snapshot";
36
+ import { ChangeDetector, DetectedChange } from "./change-detection";
37
+ import { MoveDetector } from "./move-detection";
38
+
39
+ /**
40
+ * Bidirectional sync engine implementing two-phase sync
41
+ */
42
+ export class SyncEngine {
43
+ private snapshotManager: SnapshotManager;
44
+ private changeDetector: ChangeDetector;
45
+ private moveDetector: MoveDetector;
46
+ private networkSyncEnabled: boolean = true;
47
+ private handlesToWaitOn: DocHandle<unknown>[] = [];
48
+ private syncServerStorageId?: string;
49
+
50
+ constructor(
51
+ private repo: Repo,
52
+ private rootPath: string,
53
+ excludePatterns: string[] = [],
54
+ networkSyncEnabled: boolean = true,
55
+ syncServerStorageId?: string
56
+ ) {
57
+ this.snapshotManager = new SnapshotManager(rootPath);
58
+ this.changeDetector = new ChangeDetector(repo, rootPath, excludePatterns);
59
+ this.moveDetector = new MoveDetector();
60
+ this.networkSyncEnabled = networkSyncEnabled;
61
+ this.syncServerStorageId = syncServerStorageId;
62
+ }
63
+
64
+ /**
65
+ * Determine if content should be treated as text for Automerge text operations
66
+ * Note: This method checks the runtime type. File type detection happens
67
+ * during reading with isEnhancedTextFile() which now has better dev file support.
68
+ */
69
+ private isTextContent(content: string | Uint8Array): boolean {
70
+ // Simply check the actual type of the content
71
+ return typeof content === "string";
72
+ }
73
+
74
+ /**
75
+ * Set the root directory URL in the snapshot
76
+ */
77
+ async setRootDirectoryUrl(url: AutomergeUrl): Promise<void> {
78
+ let snapshot = await this.snapshotManager.load();
79
+ if (!snapshot) {
80
+ snapshot = this.snapshotManager.createEmpty();
81
+ }
82
+ snapshot.rootDirectoryUrl = url;
83
+ await this.snapshotManager.save(snapshot);
84
+ }
85
+
86
+ /**
87
+ * Commit local changes only (no network sync)
88
+ */
89
+ async commitLocal(dryRun = false): Promise<SyncResult> {
90
+ console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
91
+
92
+ const result: SyncResult = {
93
+ success: false,
94
+ filesChanged: 0,
95
+ directoriesChanged: 0,
96
+ errors: [],
97
+ warnings: [],
98
+ };
99
+
100
+ try {
101
+ // Load current snapshot
102
+ console.log(`📸 Loading current snapshot...`);
103
+ let snapshot = await this.snapshotManager.load();
104
+ if (!snapshot) {
105
+ console.log(`📸 No snapshot found, creating empty one`);
106
+ snapshot = this.snapshotManager.createEmpty();
107
+ } else {
108
+ console.log(`📸 Snapshot loaded with ${snapshot.files.size} files`);
109
+ if (snapshot.rootDirectoryUrl) {
110
+ console.log(`🔗 Root directory URL: ${snapshot.rootDirectoryUrl}`);
111
+ }
112
+ }
113
+
114
+ // Backup snapshot before starting
115
+ if (!dryRun) {
116
+ console.log(`💾 Backing up snapshot...`);
117
+ await this.snapshotManager.backup();
118
+ }
119
+
120
+ // Detect all changes
121
+ console.log(`🔍 Detecting changes...`);
122
+ const changes = await this.changeDetector.detectChanges(snapshot);
123
+ console.log(`🔍 Found ${changes.length} changes`);
124
+
125
+ // Detect moves
126
+ console.log(`📦 Detecting moves...`);
127
+ const { moves, remainingChanges } = await this.moveDetector.detectMoves(
128
+ changes,
129
+ snapshot,
130
+ this.rootPath
131
+ );
132
+ console.log(
133
+ `📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
134
+ );
135
+
136
+ // Apply local changes only (no network sync)
137
+ console.log(`💾 Committing local changes...`);
138
+ const commitResult = await this.pushLocalChanges(
139
+ remainingChanges,
140
+ moves,
141
+ snapshot,
142
+ dryRun
143
+ );
144
+ console.log(
145
+ `💾 Commit complete: ${commitResult.filesChanged} files changed`
146
+ );
147
+
148
+ result.filesChanged += commitResult.filesChanged;
149
+ result.directoriesChanged += commitResult.directoriesChanged;
150
+ result.errors.push(...commitResult.errors);
151
+ result.warnings.push(...commitResult.warnings);
152
+
153
+ // Save updated snapshot if not dry run
154
+ if (!dryRun) {
155
+ await this.snapshotManager.save(snapshot);
156
+ }
157
+
158
+ result.success = result.errors.length === 0;
159
+ console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
160
+
161
+ return result;
162
+ } catch (error) {
163
+ console.error(`❌ Local commit failed: ${error}`);
164
+ result.errors.push({
165
+ path: this.rootPath,
166
+ operation: "commitLocal",
167
+ error: error instanceof Error ? error : new Error(String(error)),
168
+ recoverable: true,
169
+ });
170
+ result.success = false;
171
+ return result;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Run full bidirectional sync
177
+ */
178
+ async sync(dryRun = false): Promise<SyncResult> {
179
+ const result: SyncResult = {
180
+ success: false,
181
+ filesChanged: 0,
182
+ directoriesChanged: 0,
183
+ errors: [],
184
+ warnings: [],
185
+ };
186
+
187
+ // Reset handles to wait on
188
+ this.handlesToWaitOn = [];
189
+
190
+ try {
191
+ // Load current snapshot
192
+ let snapshot = await this.snapshotManager.load();
193
+ if (!snapshot) {
194
+ snapshot = this.snapshotManager.createEmpty();
195
+ }
196
+
197
+ // Backup snapshot before starting
198
+ if (!dryRun) {
199
+ await this.snapshotManager.backup();
200
+ }
201
+
202
+ // Detect all changes
203
+ const changes = await this.changeDetector.detectChanges(snapshot);
204
+
205
+ // Detect moves
206
+ const { moves, remainingChanges } = await this.moveDetector.detectMoves(
207
+ changes,
208
+ snapshot,
209
+ this.rootPath
210
+ );
211
+
212
+ if (changes.length > 0) {
213
+ console.log(`🔄 Syncing ${changes.length} changes...`);
214
+ }
215
+
216
+ // Phase 1: Push local changes to remote
217
+ const phase1Result = await this.pushLocalChanges(
218
+ remainingChanges,
219
+ moves,
220
+ snapshot,
221
+ dryRun
222
+ );
223
+
224
+ result.filesChanged += phase1Result.filesChanged;
225
+ result.directoriesChanged += phase1Result.directoriesChanged;
226
+ result.errors.push(...phase1Result.errors);
227
+ result.warnings.push(...phase1Result.warnings);
228
+
229
+ // Always wait for network sync when enabled (not just when local changes exist)
230
+ // This is critical for clone scenarios where we need to pull remote changes
231
+ if (!dryRun && this.networkSyncEnabled) {
232
+ try {
233
+ // If we have a root directory URL, wait for it to sync
234
+ if (snapshot.rootDirectoryUrl) {
235
+ const rootHandle = await this.repo.find<DirectoryDocument>(
236
+ snapshot.rootDirectoryUrl
237
+ );
238
+ this.handlesToWaitOn.push(rootHandle);
239
+ }
240
+
241
+ if (this.handlesToWaitOn.length > 0) {
242
+ await waitForSync(
243
+ this.handlesToWaitOn,
244
+ getSyncServerStorageId(this.syncServerStorageId)
245
+ );
246
+ }
247
+ } catch (error) {
248
+ console.error(`❌ Network sync failed: ${error}`);
249
+ result.warnings.push(`Network sync failed: ${error}`);
250
+ }
251
+ }
252
+
253
+ // Re-detect remote changes after network sync to ensure fresh state
254
+ // This fixes race conditions where we detect changes before server propagation
255
+ const freshChanges = await this.changeDetector.detectChanges(snapshot);
256
+ const freshRemoteChanges = freshChanges.filter(
257
+ (c) =>
258
+ c.changeType === ChangeType.REMOTE_ONLY ||
259
+ c.changeType === ChangeType.BOTH_CHANGED
260
+ );
261
+
262
+ // Phase 2: Pull remote changes to local using fresh detection
263
+ const phase2Result = await this.pullRemoteChanges(
264
+ freshRemoteChanges,
265
+ snapshot,
266
+ dryRun
267
+ );
268
+ result.filesChanged += phase2Result.filesChanged;
269
+ result.directoriesChanged += phase2Result.directoriesChanged;
270
+ result.errors.push(...phase2Result.errors);
271
+ result.warnings.push(...phase2Result.warnings);
272
+
273
+ // Save updated snapshot if not dry run
274
+ if (!dryRun) {
275
+ await this.snapshotManager.save(snapshot);
276
+ }
277
+
278
+ result.success = result.errors.length === 0;
279
+ return result;
280
+ } catch (error) {
281
+ result.errors.push({
282
+ path: "sync",
283
+ operation: "full-sync",
284
+ error: error as Error,
285
+ recoverable: false,
286
+ });
287
+ return result;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Phase 1: Push local changes to Automerge documents
293
+ */
294
+ private async pushLocalChanges(
295
+ changes: DetectedChange[],
296
+ moves: MoveCandidate[],
297
+ snapshot: SyncSnapshot,
298
+ dryRun: boolean
299
+ ): Promise<SyncResult> {
300
+ const result: SyncResult = {
301
+ success: true,
302
+ filesChanged: 0,
303
+ directoriesChanged: 0,
304
+ errors: [],
305
+ warnings: [],
306
+ };
307
+
308
+ // Process moves first
309
+ for (const move of moves) {
310
+ if (this.moveDetector.shouldAutoApply(move)) {
311
+ try {
312
+ await this.applyMoveToRemote(move, snapshot, dryRun);
313
+ result.filesChanged++;
314
+ } catch (error) {
315
+ result.errors.push({
316
+ path: move.fromPath,
317
+ operation: "move",
318
+ error: error as Error,
319
+ recoverable: true,
320
+ });
321
+ }
322
+ } else if (this.moveDetector.shouldPromptUser(move)) {
323
+ // Instead of creating a persistent loop, perform delete+create semantics
324
+ // so the working tree converges even without auto-apply.
325
+ result.warnings.push(
326
+ `Potential move detected: ${this.moveDetector.formatMove(
327
+ move
328
+ )} (${Math.round(move.similarity * 100)}% similar)`
329
+ );
330
+ }
331
+ }
332
+
333
+ // Process local changes
334
+ const localChanges = changes.filter(
335
+ (c) =>
336
+ c.changeType === ChangeType.LOCAL_ONLY ||
337
+ c.changeType === ChangeType.BOTH_CHANGED
338
+ );
339
+
340
+ for (const change of localChanges) {
341
+ try {
342
+ await this.applyLocalChangeToRemote(change, snapshot, dryRun);
343
+ result.filesChanged++;
344
+ } catch (error) {
345
+ result.errors.push({
346
+ path: change.path,
347
+ operation: "local-to-remote",
348
+ error: error as Error,
349
+ recoverable: true,
350
+ });
351
+ }
352
+ }
353
+
354
+ return result;
355
+ }
356
+
357
+ /**
358
+ * Phase 2: Pull remote changes to local filesystem
359
+ */
360
+ private async pullRemoteChanges(
361
+ changes: DetectedChange[],
362
+ snapshot: SyncSnapshot,
363
+ dryRun: boolean
364
+ ): Promise<SyncResult> {
365
+ const result: SyncResult = {
366
+ success: true,
367
+ filesChanged: 0,
368
+ directoriesChanged: 0,
369
+ errors: [],
370
+ warnings: [],
371
+ };
372
+
373
+ // Process remote changes
374
+ const remoteChanges = changes.filter(
375
+ (c) =>
376
+ c.changeType === ChangeType.REMOTE_ONLY ||
377
+ c.changeType === ChangeType.BOTH_CHANGED
378
+ );
379
+
380
+ // Sort changes by dependency order (parents before children)
381
+ const sortedChanges = this.sortChangesByDependency(remoteChanges);
382
+
383
+ for (const change of sortedChanges) {
384
+ try {
385
+ await this.applyRemoteChangeToLocal(change, snapshot, dryRun);
386
+ result.filesChanged++;
387
+ } catch (error) {
388
+ result.errors.push({
389
+ path: change.path,
390
+ operation: "remote-to-local",
391
+ error: error as Error,
392
+ recoverable: true,
393
+ });
394
+ }
395
+ }
396
+
397
+ return result;
398
+ }
399
+
400
+ /**
401
+ * Apply local file change to remote Automerge document
402
+ */
403
+ private async applyLocalChangeToRemote(
404
+ change: DetectedChange,
405
+ snapshot: SyncSnapshot,
406
+ dryRun: boolean
407
+ ): Promise<void> {
408
+ const snapshotEntry = snapshot.files.get(change.path);
409
+
410
+ if (!change.localContent) {
411
+ // File was deleted locally
412
+ if (snapshotEntry) {
413
+ console.log(`🗑️ ${change.path}`);
414
+ await this.deleteRemoteFile(
415
+ snapshotEntry.url,
416
+ dryRun,
417
+ snapshot,
418
+ change.path
419
+ );
420
+ // Remove from directory document
421
+ await this.removeFileFromDirectory(snapshot, change.path, dryRun);
422
+ if (!dryRun) {
423
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
424
+ }
425
+ }
426
+ return;
427
+ }
428
+
429
+ if (!snapshotEntry) {
430
+ // New file
431
+ console.log(`➕ ${change.path}`);
432
+ const handle = await this.createRemoteFile(change, dryRun);
433
+ if (!dryRun && handle) {
434
+ await this.addFileToDirectory(
435
+ snapshot,
436
+ change.path,
437
+ handle.url,
438
+ dryRun
439
+ );
440
+
441
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
442
+ path: normalizePath(this.rootPath + "/" + change.path),
443
+ url: handle.url,
444
+ head: handle.heads(),
445
+ extension: getFileExtension(change.path),
446
+ mimeType: getEnhancedMimeType(change.path),
447
+ });
448
+ }
449
+ } else {
450
+ // Update existing file
451
+ console.log(`📝 ${change.path}`);
452
+ await this.updateRemoteFile(
453
+ snapshotEntry.url,
454
+ change.localContent,
455
+ dryRun,
456
+ snapshot,
457
+ change.path
458
+ );
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Apply remote change to local filesystem
464
+ */
465
+ private async applyRemoteChangeToLocal(
466
+ change: DetectedChange,
467
+ snapshot: SyncSnapshot,
468
+ dryRun: boolean
469
+ ): Promise<void> {
470
+ const localPath = normalizePath(this.rootPath + "/" + change.path);
471
+
472
+ if (!change.remoteHead) {
473
+ throw new Error(
474
+ `No remote head found for remote change to${change.path}`
475
+ );
476
+ }
477
+
478
+ if (!change.remoteContent) {
479
+ // File was deleted remotely
480
+ console.log(`🗑️ ${change.path}`);
481
+ if (!dryRun) {
482
+ await removePath(localPath);
483
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
484
+ }
485
+ return;
486
+ }
487
+
488
+ // Create or update local file
489
+ if (change.changeType === ChangeType.REMOTE_ONLY) {
490
+ console.log(`⬇️ ${change.path}`);
491
+ } else {
492
+ console.log(`🔀 ${change.path}`);
493
+ }
494
+
495
+ if (!dryRun) {
496
+ await writeFileContent(localPath, change.remoteContent);
497
+
498
+ // Update or create snapshot entry for this file
499
+ const snapshotEntry = snapshot.files.get(change.path);
500
+ if (snapshotEntry) {
501
+ // Update existing entry
502
+ snapshotEntry.head = change.remoteHead;
503
+ } else {
504
+ // Create new snapshot entry for newly discovered remote file
505
+ // We need to find the remote file's URL from the directory hierarchy
506
+ if (snapshot.rootDirectoryUrl) {
507
+ try {
508
+ const fileEntry = await this.findFileInDirectoryHierarchy(
509
+ snapshot.rootDirectoryUrl,
510
+ change.path
511
+ );
512
+
513
+ if (fileEntry) {
514
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
515
+ path: localPath,
516
+ url: fileEntry.url,
517
+ head: change.remoteHead,
518
+ extension: getFileExtension(change.path),
519
+ mimeType: getEnhancedMimeType(change.path),
520
+ });
521
+ }
522
+ } catch (error) {
523
+ console.warn(
524
+ `Failed to update snapshot for remote file ${change.path}: ${error}`
525
+ );
526
+ }
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Apply move to remote documents
534
+ */
535
+ private async applyMoveToRemote(
536
+ move: MoveCandidate,
537
+ snapshot: SyncSnapshot,
538
+ dryRun: boolean
539
+ ): Promise<void> {
540
+ const fromEntry = snapshot.files.get(move.fromPath);
541
+ if (!fromEntry) return;
542
+
543
+ // Parse paths
544
+ const fromParts = move.fromPath.split("/");
545
+ const fromFileName = fromParts.pop() || "";
546
+ const fromDirPath = fromParts.join("/");
547
+
548
+ const toParts = move.toPath.split("/");
549
+ const toFileName = toParts.pop() || "";
550
+ const toDirPath = toParts.join("/");
551
+
552
+ if (!dryRun) {
553
+ // 1) Remove file entry from old directory document
554
+ if (move.fromPath !== move.toPath) {
555
+ await this.removeFileFromDirectory(snapshot, move.fromPath, dryRun);
556
+ }
557
+
558
+ // 2) Ensure destination directory document exists and add file entry there
559
+ const destDirUrl = await this.ensureDirectoryDocument(
560
+ snapshot,
561
+ toDirPath,
562
+ dryRun
563
+ );
564
+ await this.addFileToDirectory(
565
+ snapshot,
566
+ move.toPath,
567
+ fromEntry.url,
568
+ dryRun
569
+ );
570
+
571
+ // 3) Update the FileDocument name to match new basename
572
+ try {
573
+ const handle = await this.repo.find<FileDocument>(fromEntry.url);
574
+ const heads = fromEntry.head;
575
+ if (heads && heads.length > 0) {
576
+ handle.changeAt(heads, (doc: FileDocument) => {
577
+ doc.name = toFileName;
578
+ });
579
+ } else {
580
+ handle.change((doc: FileDocument) => {
581
+ doc.name = toFileName;
582
+ });
583
+ }
584
+ // Track file handle for network sync
585
+ this.handlesToWaitOn.push(handle);
586
+ } catch (e) {
587
+ console.warn(
588
+ `Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
589
+ );
590
+ }
591
+
592
+ // 4) Update snapshot entries
593
+ this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
594
+ this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
595
+ ...fromEntry,
596
+ path: normalizePath(this.rootPath + "/" + move.toPath),
597
+ head: fromEntry.head, // will be updated later when heads advance
598
+ });
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Create new remote file document
604
+ */
605
+ private async createRemoteFile(
606
+ change: DetectedChange,
607
+ dryRun: boolean
608
+ ): Promise<DocHandle<FileDocument> | null> {
609
+ if (dryRun || !change.localContent) return null;
610
+
611
+ const isText = this.isTextContent(change.localContent);
612
+
613
+ // Create initial document structure
614
+ const fileDoc: FileDocument = {
615
+ "@patchwork": { type: "file" },
616
+ name: change.path.split("/").pop() || "",
617
+ extension: getFileExtension(change.path),
618
+ mimeType: getEnhancedMimeType(change.path),
619
+ content: isText ? "" : change.localContent, // Empty string for text, actual content for binary
620
+ metadata: {
621
+ permissions: 0o644,
622
+ },
623
+ };
624
+
625
+ const handle = this.repo.create(fileDoc);
626
+
627
+ // For text files, use updateText to set the content properly
628
+ if (isText && typeof change.localContent === "string") {
629
+ handle.change((doc: FileDocument) => {
630
+ updateText(doc, ["content"], change.localContent as string);
631
+ });
632
+ }
633
+
634
+ // Always track newly created files for network sync
635
+ // (they always represent a change that needs to sync)
636
+ this.handlesToWaitOn.push(handle);
637
+
638
+ return handle;
639
+ }
640
+
641
+ /**
642
+ * Update existing remote file document
643
+ */
644
+ private async updateRemoteFile(
645
+ url: AutomergeUrl,
646
+ content: string | Uint8Array,
647
+ dryRun: boolean,
648
+ snapshot: SyncSnapshot,
649
+ filePath: string
650
+ ): Promise<void> {
651
+ if (dryRun) return;
652
+
653
+ const handle = await this.repo.find<FileDocument>(url);
654
+
655
+ // Check if content actually changed before tracking for sync
656
+ const doc = await handle.doc();
657
+ const currentContent = doc?.content;
658
+ const contentChanged = !isContentEqual(content, currentContent);
659
+
660
+ if (!contentChanged) {
661
+ return;
662
+ }
663
+
664
+ const snapshotEntry = snapshot.files.get(filePath);
665
+ const heads = snapshotEntry?.head;
666
+
667
+ if (!heads) {
668
+ throw new Error(`No heads found for ${url}`);
669
+ }
670
+
671
+ handle.changeAt(heads, (doc: FileDocument) => {
672
+ const isText = this.isTextContent(content);
673
+ if (isText && typeof content === "string") {
674
+ updateText(doc, ["content"], content);
675
+ } else {
676
+ doc.content = content;
677
+ }
678
+ });
679
+
680
+ if (!dryRun) {
681
+ snapshot.files.set(filePath, {
682
+ ...snapshotEntry,
683
+ head: handle.heads(),
684
+ });
685
+ }
686
+
687
+ // Only track files that actually changed content
688
+ this.handlesToWaitOn.push(handle);
689
+ }
690
+
691
+ /**
692
+ * Delete remote file document
693
+ */
694
+ private async deleteRemoteFile(
695
+ url: AutomergeUrl,
696
+ dryRun: boolean,
697
+ snapshot?: SyncSnapshot,
698
+ filePath?: string
699
+ ): Promise<void> {
700
+ if (dryRun) return;
701
+
702
+ // In Automerge, we don't actually delete documents
703
+ // They become orphaned and will be garbage collected
704
+ // For now, we just mark them as deleted by clearing content
705
+ const handle = await this.repo.find<FileDocument>(url);
706
+ // const doc = await handle.doc(); // no longer needed
707
+ let heads;
708
+ if (snapshot && filePath) {
709
+ heads = snapshot.files.get(filePath)?.head;
710
+ }
711
+ if (heads) {
712
+ handle.changeAt(heads, (doc: FileDocument) => {
713
+ doc.content = "";
714
+ });
715
+ } else {
716
+ handle.change((doc: FileDocument) => {
717
+ doc.content = "";
718
+ });
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Add file entry to appropriate directory document (maintains hierarchy)
724
+ */
725
+ private async addFileToDirectory(
726
+ snapshot: SyncSnapshot,
727
+ filePath: string,
728
+ fileUrl: AutomergeUrl,
729
+ dryRun: boolean
730
+ ): Promise<void> {
731
+ if (dryRun || !snapshot.rootDirectoryUrl) return;
732
+
733
+ const pathParts = filePath.split("/");
734
+ const fileName = pathParts.pop() || "";
735
+ const directoryPath = pathParts.join("/");
736
+
737
+ // Get or create the parent directory document
738
+ const parentDirUrl = await this.ensureDirectoryDocument(
739
+ snapshot,
740
+ directoryPath,
741
+ dryRun
742
+ );
743
+
744
+ console.log(
745
+ `🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
746
+ );
747
+
748
+ const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
749
+
750
+ let didChange = false;
751
+ const snapshotEntry = snapshot.directories.get(directoryPath);
752
+ const heads = snapshotEntry?.head;
753
+ if (heads) {
754
+ dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
755
+ const existingIndex = doc.docs.findIndex(
756
+ (entry) => entry.name === fileName && entry.type === "file"
757
+ );
758
+ if (existingIndex === -1) {
759
+ doc.docs.push({
760
+ name: fileName,
761
+ type: "file",
762
+ url: fileUrl,
763
+ });
764
+ didChange = true;
765
+ }
766
+ });
767
+ } else {
768
+ dirHandle.change((doc: DirectoryDocument) => {
769
+ const existingIndex = doc.docs.findIndex(
770
+ (entry) => entry.name === fileName && entry.type === "file"
771
+ );
772
+ if (existingIndex === -1) {
773
+ doc.docs.push({
774
+ name: fileName,
775
+ type: "file",
776
+ url: fileUrl,
777
+ });
778
+ didChange = true;
779
+ }
780
+ });
781
+ }
782
+ if (didChange) {
783
+ this.handlesToWaitOn.push(dirHandle);
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Ensure directory document exists for the given path, creating hierarchy as needed
789
+ * First checks for existing shared directories before creating new ones
790
+ */
791
+ private async ensureDirectoryDocument(
792
+ snapshot: SyncSnapshot,
793
+ directoryPath: string,
794
+ dryRun: boolean
795
+ ): Promise<AutomergeUrl> {
796
+ // Root directory case
797
+ if (!directoryPath || directoryPath === "") {
798
+ return snapshot.rootDirectoryUrl!;
799
+ }
800
+
801
+ // Check if we already have this directory in snapshot
802
+ const existingDir = snapshot.directories.get(directoryPath);
803
+ if (existingDir) {
804
+ return existingDir.url;
805
+ }
806
+
807
+ // Split path into parent and current directory name
808
+ const pathParts = directoryPath.split("/");
809
+ const currentDirName = pathParts.pop() || "";
810
+ const parentPath = pathParts.join("/");
811
+
812
+ // Ensure parent directory exists first (recursive)
813
+ const parentDirUrl = await this.ensureDirectoryDocument(
814
+ snapshot,
815
+ parentPath,
816
+ dryRun
817
+ );
818
+
819
+ // DISCOVERY: Check if directory already exists in parent on server
820
+ try {
821
+ const parentHandle = await this.repo.find<DirectoryDocument>(
822
+ parentDirUrl
823
+ );
824
+ const parentDoc = await parentHandle.doc();
825
+
826
+ if (parentDoc) {
827
+ const existingDirEntry = parentDoc.docs.find(
828
+ (entry: { name: string; type: string; url: AutomergeUrl }) =>
829
+ entry.name === currentDirName && entry.type === "folder"
830
+ );
831
+
832
+ if (existingDirEntry) {
833
+ // Resolve the actual directory handle and use its current heads
834
+ // Directory entries in parent docs may not carry valid heads
835
+ try {
836
+ const childDirHandle = await this.repo.find<DirectoryDocument>(
837
+ existingDirEntry.url
838
+ );
839
+ const childHeads = childDirHandle.heads();
840
+
841
+ // Update snapshot with discovered directory using validated heads
842
+ if (!dryRun) {
843
+ this.snapshotManager.updateDirectoryEntry(
844
+ snapshot,
845
+ directoryPath,
846
+ {
847
+ path: normalizePath(this.rootPath + "/" + directoryPath),
848
+ url: existingDirEntry.url,
849
+ head: childHeads,
850
+ entries: [],
851
+ }
852
+ );
853
+ }
854
+
855
+ return existingDirEntry.url;
856
+ } catch (resolveErr) {
857
+ console.warn(
858
+ `Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
859
+ );
860
+ // Fall through to create a fresh directory document
861
+ }
862
+ }
863
+ }
864
+ } catch (error) {
865
+ console.warn(
866
+ `Failed to check for existing directory ${currentDirName}: ${error}`
867
+ );
868
+ }
869
+
870
+ // CREATE: Directory doesn't exist, create new one
871
+ const dirDoc: DirectoryDocument = {
872
+ "@patchwork": { type: "folder" },
873
+ docs: [],
874
+ };
875
+
876
+ const dirHandle = this.repo.create(dirDoc);
877
+
878
+ // Add this directory to its parent
879
+ const parentHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
880
+
881
+ let didChange = false;
882
+ parentHandle.change((doc: DirectoryDocument) => {
883
+ // Double-check that entry doesn't exist (race condition protection)
884
+ const existingIndex = doc.docs.findIndex(
885
+ (entry: { name: string; type: string; url: AutomergeUrl }) =>
886
+ entry.name === currentDirName && entry.type === "folder"
887
+ );
888
+ if (existingIndex === -1) {
889
+ doc.docs.push({
890
+ name: currentDirName,
891
+ type: "folder",
892
+ url: dirHandle.url,
893
+ });
894
+ didChange = true;
895
+ }
896
+ });
897
+
898
+ // Track directory handles for sync
899
+ if (!dryRun) {
900
+ this.handlesToWaitOn.push(dirHandle);
901
+ if (didChange) {
902
+ this.handlesToWaitOn.push(parentHandle);
903
+ }
904
+
905
+ // Update snapshot with new directory
906
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
907
+ path: normalizePath(this.rootPath + "/" + directoryPath),
908
+ url: dirHandle.url,
909
+ head: dirHandle.heads(),
910
+ entries: [],
911
+ });
912
+ }
913
+
914
+ return dirHandle.url;
915
+ }
916
+
917
+ /**
918
+ * Remove file entry from directory document
919
+ */
920
+ private async removeFileFromDirectory(
921
+ snapshot: SyncSnapshot,
922
+ filePath: string,
923
+ dryRun: boolean
924
+ ): Promise<void> {
925
+ if (dryRun || !snapshot.rootDirectoryUrl) return;
926
+
927
+ const pathParts = filePath.split("/");
928
+ const fileName = pathParts.pop() || "";
929
+ const directoryPath = pathParts.join("/");
930
+
931
+ // Get the parent directory URL
932
+ let parentDirUrl: AutomergeUrl;
933
+ if (!directoryPath || directoryPath === "") {
934
+ parentDirUrl = snapshot.rootDirectoryUrl;
935
+ } else {
936
+ const existingDir = snapshot.directories.get(directoryPath);
937
+ if (!existingDir) {
938
+ console.warn(
939
+ `Directory ${directoryPath} not found in snapshot for file removal`
940
+ );
941
+ return;
942
+ }
943
+ parentDirUrl = existingDir.url;
944
+ }
945
+
946
+ try {
947
+ const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
948
+
949
+ // Track this handle for network sync waiting
950
+ this.handlesToWaitOn.push(dirHandle);
951
+ const snapshotEntry = snapshot.directories.get(directoryPath);
952
+ const heads = snapshotEntry?.head;
953
+ if (heads) {
954
+ dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
955
+ const indexToRemove = doc.docs.findIndex(
956
+ (entry) => entry.name === fileName && entry.type === "file"
957
+ );
958
+ if (indexToRemove !== -1) {
959
+ doc.docs.splice(indexToRemove, 1);
960
+ console.log(
961
+ `🗑️ Removed ${fileName} from directory ${
962
+ directoryPath || "root"
963
+ }`
964
+ );
965
+ }
966
+ });
967
+ } else {
968
+ dirHandle.change((doc: DirectoryDocument) => {
969
+ const indexToRemove = doc.docs.findIndex(
970
+ (entry) => entry.name === fileName && entry.type === "file"
971
+ );
972
+ if (indexToRemove !== -1) {
973
+ doc.docs.splice(indexToRemove, 1);
974
+ console.log(
975
+ `🗑️ Removed ${fileName} from directory ${
976
+ directoryPath || "root"
977
+ }`
978
+ );
979
+ }
980
+ });
981
+ }
982
+ } catch (error) {
983
+ console.warn(
984
+ `Failed to remove ${fileName} from directory ${
985
+ directoryPath || "root"
986
+ }: ${error}`
987
+ );
988
+ throw error;
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Find a file in the directory hierarchy by path
994
+ */
995
+ private async findFileInDirectoryHierarchy(
996
+ directoryUrl: AutomergeUrl,
997
+ filePath: string
998
+ ): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
999
+ try {
1000
+ const pathParts = filePath.split("/");
1001
+ let currentDirUrl = directoryUrl;
1002
+
1003
+ // Navigate through directories to find the parent directory
1004
+ for (let i = 0; i < pathParts.length - 1; i++) {
1005
+ const dirName = pathParts[i];
1006
+ const dirHandle = await this.repo.find<DirectoryDocument>(
1007
+ currentDirUrl
1008
+ );
1009
+ const dirDoc = await dirHandle.doc();
1010
+
1011
+ if (!dirDoc) return null;
1012
+
1013
+ const subDirEntry = dirDoc.docs.find(
1014
+ (entry: { name: string; type: string; url: AutomergeUrl }) =>
1015
+ entry.name === dirName && entry.type === "folder"
1016
+ );
1017
+
1018
+ if (!subDirEntry) return null;
1019
+ currentDirUrl = subDirEntry.url;
1020
+ }
1021
+
1022
+ // Now look for the file in the final directory
1023
+ const fileName = pathParts[pathParts.length - 1];
1024
+ const finalDirHandle = await this.repo.find<DirectoryDocument>(
1025
+ currentDirUrl
1026
+ );
1027
+ const finalDirDoc = await finalDirHandle.doc();
1028
+
1029
+ if (!finalDirDoc) return null;
1030
+
1031
+ const fileEntry = finalDirDoc.docs.find(
1032
+ (entry: { name: string; type: string; url: AutomergeUrl }) =>
1033
+ entry.name === fileName && entry.type === "file"
1034
+ );
1035
+
1036
+ return fileEntry || null;
1037
+ } catch (error) {
1038
+ console.warn(
1039
+ `Failed to find file ${filePath} in directory hierarchy: ${error}`
1040
+ );
1041
+ return null;
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Sort changes by dependency order
1047
+ */
1048
+ private sortChangesByDependency(changes: DetectedChange[]): DetectedChange[] {
1049
+ // Sort by path depth (shallower paths first)
1050
+ return changes.sort((a, b) => {
1051
+ const depthA = a.path.split("/").length;
1052
+ const depthB = b.path.split("/").length;
1053
+ return depthA - depthB;
1054
+ });
1055
+ }
1056
+
1057
+ /**
1058
+ * Get sync status
1059
+ */
1060
+ async getStatus(): Promise<{
1061
+ snapshot: SyncSnapshot | null;
1062
+ hasChanges: boolean;
1063
+ changeCount: number;
1064
+ lastSync: Date | null;
1065
+ }> {
1066
+ const snapshot = await this.snapshotManager.load();
1067
+
1068
+ if (!snapshot) {
1069
+ return {
1070
+ snapshot: null,
1071
+ hasChanges: false,
1072
+ changeCount: 0,
1073
+ lastSync: null,
1074
+ };
1075
+ }
1076
+
1077
+ const changes = await this.changeDetector.detectChanges(snapshot);
1078
+
1079
+ return {
1080
+ snapshot,
1081
+ hasChanges: changes.length > 0,
1082
+ changeCount: changes.length,
1083
+ lastSync: new Date(snapshot.timestamp),
1084
+ };
1085
+ }
1086
+
1087
+ /**
1088
+ * Preview changes without applying them
1089
+ */
1090
+ async previewChanges(): Promise<{
1091
+ changes: DetectedChange[];
1092
+ moves: MoveCandidate[];
1093
+ summary: string;
1094
+ }> {
1095
+ const snapshot = await this.snapshotManager.load();
1096
+ if (!snapshot) {
1097
+ return {
1098
+ changes: [],
1099
+ moves: [],
1100
+ summary: "No snapshot found - run init first",
1101
+ };
1102
+ }
1103
+
1104
+ const changes = await this.changeDetector.detectChanges(snapshot);
1105
+ const { moves } = await this.moveDetector.detectMoves(
1106
+ changes,
1107
+ snapshot,
1108
+ this.rootPath
1109
+ );
1110
+
1111
+ const summary = this.generateChangeSummary(changes, moves);
1112
+
1113
+ return { changes, moves, summary };
1114
+ }
1115
+
1116
+ /**
1117
+ * Generate human-readable summary of changes
1118
+ */
1119
+ private generateChangeSummary(
1120
+ changes: DetectedChange[],
1121
+ moves: MoveCandidate[]
1122
+ ): string {
1123
+ const localChanges = changes.filter(
1124
+ (c) =>
1125
+ c.changeType === ChangeType.LOCAL_ONLY ||
1126
+ c.changeType === ChangeType.BOTH_CHANGED
1127
+ ).length;
1128
+
1129
+ const remoteChanges = changes.filter(
1130
+ (c) =>
1131
+ c.changeType === ChangeType.REMOTE_ONLY ||
1132
+ c.changeType === ChangeType.BOTH_CHANGED
1133
+ ).length;
1134
+
1135
+ const conflicts = changes.filter(
1136
+ (c) => c.changeType === ChangeType.BOTH_CHANGED
1137
+ ).length;
1138
+
1139
+ const parts: string[] = [];
1140
+
1141
+ if (localChanges > 0) {
1142
+ parts.push(`${localChanges} local change${localChanges > 1 ? "s" : ""}`);
1143
+ }
1144
+
1145
+ if (remoteChanges > 0) {
1146
+ parts.push(
1147
+ `${remoteChanges} remote change${remoteChanges > 1 ? "s" : ""}`
1148
+ );
1149
+ }
1150
+
1151
+ if (moves.length > 0) {
1152
+ parts.push(
1153
+ `${moves.length} potential move${moves.length > 1 ? "s" : ""}`
1154
+ );
1155
+ }
1156
+
1157
+ if (conflicts > 0) {
1158
+ parts.push(`${conflicts} conflict${conflicts > 1 ? "s" : ""}`);
1159
+ }
1160
+
1161
+ if (parts.length === 0) {
1162
+ return "No changes detected";
1163
+ }
1164
+
1165
+ return parts.join(", ");
1166
+ }
1167
+ }