pushwork 1.0.4 → 1.0.7

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 (195) hide show
  1. package/README.md +87 -328
  2. package/dist/.pushwork/automerge/3P/Dm3ekE2pmjGnWvDaG3vSR7ww98/snapshot/aa2349c94955ea561f698720142f9d884a6872d9f82dc332d578c216beb0df0e +0 -0
  3. package/dist/.pushwork/automerge/st/orage-adapter-id +1 -0
  4. package/dist/.pushwork/config.json +15 -0
  5. package/dist/.pushwork/snapshot.json +7 -0
  6. package/dist/cli.js +231 -170
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands.d.ts +51 -0
  9. package/dist/commands.d.ts.map +1 -0
  10. package/dist/commands.js +799 -0
  11. package/dist/commands.js.map +1 -0
  12. package/dist/core/change-detection.d.ts +6 -19
  13. package/dist/core/change-detection.d.ts.map +1 -1
  14. package/dist/core/change-detection.js +101 -80
  15. package/dist/core/change-detection.js.map +1 -1
  16. package/dist/{config/index.d.ts → core/config.d.ts} +13 -3
  17. package/dist/core/config.d.ts.map +1 -0
  18. package/dist/{config/index.js → core/config.js} +55 -73
  19. package/dist/core/config.js.map +1 -0
  20. package/dist/core/index.d.ts +1 -0
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/index.js.map +1 -1
  24. package/dist/core/move-detection.d.ts +12 -50
  25. package/dist/core/move-detection.d.ts.map +1 -1
  26. package/dist/core/move-detection.js +58 -139
  27. package/dist/core/move-detection.js.map +1 -1
  28. package/dist/core/snapshot.d.ts +0 -4
  29. package/dist/core/snapshot.d.ts.map +1 -1
  30. package/dist/core/snapshot.js +2 -11
  31. package/dist/core/snapshot.js.map +1 -1
  32. package/dist/core/sync-engine.d.ts +5 -11
  33. package/dist/core/sync-engine.d.ts.map +1 -1
  34. package/dist/core/sync-engine.js +220 -362
  35. package/dist/core/sync-engine.js.map +1 -1
  36. package/dist/index.d.ts +0 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +0 -6
  39. package/dist/index.js.map +1 -1
  40. package/dist/types/config.d.ts +43 -67
  41. package/dist/types/config.d.ts.map +1 -1
  42. package/dist/types/config.js +6 -0
  43. package/dist/types/config.js.map +1 -1
  44. package/dist/types/documents.d.ts +15 -3
  45. package/dist/types/documents.d.ts.map +1 -1
  46. package/dist/types/documents.js.map +1 -1
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/index.js +0 -3
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/types/snapshot.d.ts +3 -21
  51. package/dist/types/snapshot.d.ts.map +1 -1
  52. package/dist/types/snapshot.js +0 -14
  53. package/dist/types/snapshot.js.map +1 -1
  54. package/dist/utils/content.d.ts.map +1 -1
  55. package/dist/utils/content.js +2 -6
  56. package/dist/utils/content.js.map +1 -1
  57. package/dist/utils/directory.d.ts +10 -0
  58. package/dist/utils/directory.d.ts.map +1 -0
  59. package/dist/utils/directory.js +37 -0
  60. package/dist/utils/directory.js.map +1 -0
  61. package/dist/utils/fs.d.ts +15 -2
  62. package/dist/utils/fs.d.ts.map +1 -1
  63. package/dist/utils/fs.js +63 -53
  64. package/dist/utils/fs.js.map +1 -1
  65. package/dist/utils/index.d.ts +1 -1
  66. package/dist/utils/index.d.ts.map +1 -1
  67. package/dist/utils/index.js +1 -4
  68. package/dist/utils/index.js.map +1 -1
  69. package/dist/utils/mime-types.d.ts.map +1 -1
  70. package/dist/utils/mime-types.js +11 -4
  71. package/dist/utils/mime-types.js.map +1 -1
  72. package/dist/utils/network-sync.d.ts +0 -6
  73. package/dist/utils/network-sync.d.ts.map +1 -1
  74. package/dist/utils/network-sync.js +55 -99
  75. package/dist/utils/network-sync.js.map +1 -1
  76. package/dist/utils/output.d.ts +129 -0
  77. package/dist/utils/output.d.ts.map +1 -0
  78. package/dist/utils/output.js +375 -0
  79. package/dist/utils/output.js.map +1 -0
  80. package/dist/utils/repo-factory.d.ts +2 -6
  81. package/dist/utils/repo-factory.d.ts.map +1 -1
  82. package/dist/utils/repo-factory.js +8 -22
  83. package/dist/utils/repo-factory.js.map +1 -1
  84. package/dist/utils/string-similarity.d.ts +14 -0
  85. package/dist/utils/string-similarity.d.ts.map +1 -0
  86. package/dist/utils/string-similarity.js +43 -0
  87. package/dist/utils/string-similarity.js.map +1 -0
  88. package/dist/utils/trace.d.ts +19 -0
  89. package/dist/utils/trace.d.ts.map +1 -0
  90. package/dist/utils/trace.js +68 -0
  91. package/dist/utils/trace.js.map +1 -0
  92. package/package.json +17 -12
  93. package/src/cli.ts +326 -252
  94. package/src/commands.ts +988 -0
  95. package/src/core/change-detection.ts +199 -162
  96. package/src/{config/index.ts → core/config.ts} +65 -82
  97. package/src/core/index.ts +1 -1
  98. package/src/core/move-detection.ts +74 -180
  99. package/src/core/snapshot.ts +2 -12
  100. package/src/core/sync-engine.ts +248 -499
  101. package/src/index.ts +0 -10
  102. package/src/types/config.ts +50 -72
  103. package/src/types/documents.ts +16 -3
  104. package/src/types/index.ts +0 -5
  105. package/src/types/snapshot.ts +1 -23
  106. package/src/utils/content.ts +2 -6
  107. package/src/utils/directory.ts +50 -0
  108. package/src/utils/fs.ts +67 -56
  109. package/src/utils/index.ts +1 -6
  110. package/src/utils/mime-types.ts +12 -4
  111. package/src/utils/network-sync.ts +79 -137
  112. package/src/utils/output.ts +450 -0
  113. package/src/utils/repo-factory.ts +13 -31
  114. package/src/utils/string-similarity.ts +54 -0
  115. package/src/utils/trace.ts +70 -0
  116. package/test/integration/exclude-patterns.test.ts +6 -15
  117. package/test/integration/fuzzer.test.ts +308 -391
  118. package/test/integration/init-sync.test.ts +89 -0
  119. package/test/integration/sync-deletion.test.ts +2 -61
  120. package/test/integration/sync-flow.test.ts +4 -24
  121. package/test/jest.setup.ts +34 -0
  122. package/test/unit/deletion-behavior.test.ts +3 -14
  123. package/test/unit/enhanced-mime-detection.test.ts +0 -22
  124. package/test/unit/snapshot.test.ts +2 -29
  125. package/test/unit/sync-convergence.test.ts +3 -198
  126. package/test/unit/sync-timing.test.ts +0 -44
  127. package/test/unit/utils.test.ts +0 -2
  128. package/tsconfig.json +3 -3
  129. package/dist/browser/browser-sync-engine.d.ts +0 -64
  130. package/dist/browser/browser-sync-engine.d.ts.map +0 -1
  131. package/dist/browser/browser-sync-engine.js +0 -303
  132. package/dist/browser/browser-sync-engine.js.map +0 -1
  133. package/dist/browser/filesystem-adapter.d.ts +0 -84
  134. package/dist/browser/filesystem-adapter.d.ts.map +0 -1
  135. package/dist/browser/filesystem-adapter.js +0 -413
  136. package/dist/browser/filesystem-adapter.js.map +0 -1
  137. package/dist/browser/index.d.ts +0 -36
  138. package/dist/browser/index.d.ts.map +0 -1
  139. package/dist/browser/index.js +0 -90
  140. package/dist/browser/index.js.map +0 -1
  141. package/dist/browser/types.d.ts +0 -70
  142. package/dist/browser/types.d.ts.map +0 -1
  143. package/dist/browser/types.js +0 -6
  144. package/dist/browser/types.js.map +0 -1
  145. package/dist/cli/commands.d.ts +0 -77
  146. package/dist/cli/commands.d.ts.map +0 -1
  147. package/dist/cli/commands.js +0 -904
  148. package/dist/cli/commands.js.map +0 -1
  149. package/dist/cli/index.d.ts +0 -2
  150. package/dist/cli/index.d.ts.map +0 -1
  151. package/dist/cli/index.js +0 -19
  152. package/dist/cli/index.js.map +0 -1
  153. package/dist/config/index.d.ts.map +0 -1
  154. package/dist/config/index.js.map +0 -1
  155. package/dist/core/isomorphic-snapshot.d.ts +0 -58
  156. package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
  157. package/dist/core/isomorphic-snapshot.js +0 -204
  158. package/dist/core/isomorphic-snapshot.js.map +0 -1
  159. package/dist/platform/browser-filesystem.d.ts +0 -26
  160. package/dist/platform/browser-filesystem.d.ts.map +0 -1
  161. package/dist/platform/browser-filesystem.js +0 -91
  162. package/dist/platform/browser-filesystem.js.map +0 -1
  163. package/dist/platform/filesystem.d.ts +0 -29
  164. package/dist/platform/filesystem.d.ts.map +0 -1
  165. package/dist/platform/filesystem.js +0 -65
  166. package/dist/platform/filesystem.js.map +0 -1
  167. package/dist/platform/node-filesystem.d.ts +0 -21
  168. package/dist/platform/node-filesystem.d.ts.map +0 -1
  169. package/dist/platform/node-filesystem.js +0 -93
  170. package/dist/platform/node-filesystem.js.map +0 -1
  171. package/dist/utils/content-similarity.d.ts +0 -53
  172. package/dist/utils/content-similarity.d.ts.map +0 -1
  173. package/dist/utils/content-similarity.js +0 -155
  174. package/dist/utils/content-similarity.js.map +0 -1
  175. package/dist/utils/fs-browser.d.ts +0 -57
  176. package/dist/utils/fs-browser.d.ts.map +0 -1
  177. package/dist/utils/fs-browser.js +0 -311
  178. package/dist/utils/fs-browser.js.map +0 -1
  179. package/dist/utils/fs-node.d.ts +0 -53
  180. package/dist/utils/fs-node.d.ts.map +0 -1
  181. package/dist/utils/fs-node.js +0 -220
  182. package/dist/utils/fs-node.js.map +0 -1
  183. package/dist/utils/isomorphic.d.ts +0 -29
  184. package/dist/utils/isomorphic.d.ts.map +0 -1
  185. package/dist/utils/isomorphic.js +0 -139
  186. package/dist/utils/isomorphic.js.map +0 -1
  187. package/dist/utils/pure.d.ts +0 -25
  188. package/dist/utils/pure.d.ts.map +0 -1
  189. package/dist/utils/pure.js +0 -112
  190. package/dist/utils/pure.js.map +0 -1
  191. package/src/cli/commands.ts +0 -1207
  192. package/src/cli/index.ts +0 -2
  193. package/src/utils/content-similarity.ts +0 -194
  194. package/test/README-TESTING-GAPS.md +0 -174
  195. package/test/unit/content-similarity.test.ts +0 -236
@@ -1,40 +1,39 @@
1
- import {
2
- AutomergeUrl,
3
- Repo,
4
- updateText,
5
- DocHandle,
6
- UrlHeads,
7
- } from "@automerge/automerge-repo";
1
+ import { AutomergeUrl, Repo, DocHandle } from "@automerge/automerge-repo";
8
2
  import * as A from "@automerge/automerge";
9
3
  import {
10
4
  SyncSnapshot,
11
5
  SyncResult,
12
- SyncError,
13
- SyncOperation,
14
- PendingSyncOperation,
15
6
  FileDocument,
16
7
  DirectoryDocument,
17
- FileType,
18
8
  ChangeType,
19
9
  MoveCandidate,
10
+ DirectoryConfig,
11
+ DetectedChange,
20
12
  } from "../types";
21
13
  import {
22
- readFileContent,
23
14
  writeFileContent,
24
15
  removePath,
25
- movePath,
26
- ensureDirectoryExists,
27
16
  getFileExtension,
28
- normalizePath,
29
- getRelativePath,
30
17
  getEnhancedMimeType,
31
- isEnhancedTextFile,
18
+ formatRelativePath,
19
+ findFileInDirectoryHierarchy,
20
+ joinAndNormalizePath,
32
21
  } from "../utils";
33
22
  import { isContentEqual } from "../utils/content";
34
- import { waitForSync, getSyncServerStorageId } from "../utils/network-sync";
23
+ import { waitForSync } from "../utils/network-sync";
35
24
  import { SnapshotManager } from "./snapshot";
36
- import { ChangeDetector, DetectedChange } from "./change-detection";
25
+ import { ChangeDetector } from "./change-detection";
37
26
  import { MoveDetector } from "./move-detection";
27
+ import { out } from "../utils/output";
28
+
29
+ /**
30
+ * Post-sync delay constants for network propagation
31
+ * These delays allow the WebSocket protocol to propagate peer changes after
32
+ * our changes reach the server. waitForSync only ensures OUR changes reached
33
+ * the server, not that we've RECEIVED changes from other peers.
34
+ * TODO: remove need for this to exist.
35
+ */
36
+ const POST_SYNC_DELAY_MS = 200; // After we pushed changes
38
37
 
39
38
  /**
40
39
  * Bidirectional sync engine implementing two-phase sync
@@ -43,22 +42,22 @@ export class SyncEngine {
43
42
  private snapshotManager: SnapshotManager;
44
43
  private changeDetector: ChangeDetector;
45
44
  private moveDetector: MoveDetector;
46
- private networkSyncEnabled: boolean = true;
47
45
  private handlesToWaitOn: DocHandle<unknown>[] = [];
48
- private syncServerStorageId?: string;
46
+ private config: DirectoryConfig;
49
47
 
50
48
  constructor(
51
49
  private repo: Repo,
52
50
  private rootPath: string,
53
- excludePatterns: string[] = [],
54
- networkSyncEnabled: boolean = true,
55
- syncServerStorageId?: string
51
+ config: DirectoryConfig
56
52
  ) {
53
+ this.config = config;
57
54
  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;
55
+ this.changeDetector = new ChangeDetector(
56
+ repo,
57
+ rootPath,
58
+ config.exclude_patterns
59
+ );
60
+ this.moveDetector = new MoveDetector(config.sync.move_detection_threshold);
62
61
  }
63
62
 
64
63
  /**
@@ -86,9 +85,7 @@ export class SyncEngine {
86
85
  /**
87
86
  * Commit local changes only (no network sync)
88
87
  */
89
- async commitLocal(dryRun = false): Promise<SyncResult> {
90
- console.log(`🚀 Starting local commit process (dryRun: ${dryRun})`);
91
-
88
+ async commitLocal(): Promise<SyncResult> {
92
89
  const result: SyncResult = {
93
90
  success: false,
94
91
  filesChanged: 0,
@@ -99,50 +96,25 @@ export class SyncEngine {
99
96
 
100
97
  try {
101
98
  // Load current snapshot
102
- console.log(`📸 Loading current snapshot...`);
103
99
  let snapshot = await this.snapshotManager.load();
104
100
  if (!snapshot) {
105
- console.log(`📸 No snapshot found, creating empty one`);
106
101
  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
102
  }
119
103
 
120
104
  // Detect all changes
121
- console.log(`🔍 Detecting changes...`);
122
105
  const changes = await this.changeDetector.detectChanges(snapshot);
123
- console.log(`🔍 Found ${changes.length} changes`);
124
106
 
125
107
  // Detect moves
126
- console.log(`📦 Detecting moves...`);
127
108
  const { moves, remainingChanges } = await this.moveDetector.detectMoves(
128
109
  changes,
129
- snapshot,
130
- this.rootPath
131
- );
132
- console.log(
133
- `📦 Found ${moves.length} moves, ${remainingChanges.length} remaining changes`
110
+ snapshot
134
111
  );
135
112
 
136
113
  // Apply local changes only (no network sync)
137
- console.log(`💾 Committing local changes...`);
138
114
  const commitResult = await this.pushLocalChanges(
139
115
  remainingChanges,
140
116
  moves,
141
- snapshot,
142
- dryRun
143
- );
144
- console.log(
145
- `💾 Commit complete: ${commitResult.filesChanged} files changed`
117
+ snapshot
146
118
  );
147
119
 
148
120
  result.filesChanged += commitResult.filesChanged;
@@ -154,20 +126,16 @@ export class SyncEngine {
154
126
  const hasChanges =
155
127
  result.filesChanged > 0 || result.directoriesChanged > 0;
156
128
  if (hasChanges) {
157
- await this.touchRootDirectory(snapshot, dryRun);
129
+ await this.touchRootDirectory(snapshot);
158
130
  }
159
131
 
160
- // Save updated snapshot if not dry run
161
- if (!dryRun) {
162
- await this.snapshotManager.save(snapshot);
163
- }
132
+ // Save updated snapshot
133
+ await this.snapshotManager.save(snapshot);
164
134
 
165
135
  result.success = result.errors.length === 0;
166
- console.log(`💾 Local commit ${result.success ? "completed" : "failed"}`);
167
136
 
168
137
  return result;
169
138
  } catch (error) {
170
- console.error(`❌ Local commit failed: ${error}`);
171
139
  result.errors.push({
172
140
  path: this.rootPath,
173
141
  operation: "commitLocal",
@@ -182,16 +150,14 @@ export class SyncEngine {
182
150
  /**
183
151
  * Run full bidirectional sync
184
152
  */
185
- async sync(dryRun = false): Promise<SyncResult> {
186
- const syncStartTime = Date.now();
187
- const timings: { [key: string]: number } = {};
188
-
153
+ async sync(): Promise<SyncResult> {
189
154
  const result: SyncResult = {
190
155
  success: false,
191
156
  filesChanged: 0,
192
157
  directoriesChanged: 0,
193
158
  errors: [],
194
159
  warnings: [],
160
+ timings: {},
195
161
  };
196
162
 
197
163
  // Reset handles to wait on
@@ -199,47 +165,25 @@ export class SyncEngine {
199
165
 
200
166
  try {
201
167
  // Load current snapshot
202
- const t0 = Date.now();
203
- let snapshot = await this.snapshotManager.load();
204
- timings["load_snapshot"] = Date.now() - t0;
205
- if (!snapshot) {
206
- snapshot = this.snapshotManager.createEmpty();
207
- }
208
-
209
- // Backup snapshot before starting
210
- const t1 = Date.now();
211
- if (!dryRun) {
212
- await this.snapshotManager.backup();
213
- }
214
- timings["backup_snapshot"] = Date.now() - t1;
168
+ const snapshot =
169
+ (await this.snapshotManager.load()) ||
170
+ this.snapshotManager.createEmpty();
215
171
 
216
172
  // Detect all changes
217
- const t2 = Date.now();
218
173
  const changes = await this.changeDetector.detectChanges(snapshot);
219
- timings["detect_changes"] = Date.now() - t2;
220
174
 
221
175
  // Detect moves
222
- const t3 = Date.now();
223
176
  const { moves, remainingChanges } = await this.moveDetector.detectMoves(
224
177
  changes,
225
- snapshot,
226
- this.rootPath
178
+ snapshot
227
179
  );
228
- timings["detect_moves"] = Date.now() - t3;
229
-
230
- if (changes.length > 0) {
231
- console.log(`🔄 Syncing ${changes.length} changes...`);
232
- }
233
180
 
234
181
  // Phase 1: Push local changes to remote
235
- const t4 = Date.now();
236
182
  const phase1Result = await this.pushLocalChanges(
237
183
  remainingChanges,
238
184
  moves,
239
- snapshot,
240
- dryRun
185
+ snapshot
241
186
  );
242
- timings["phase1_push"] = Date.now() - t4;
243
187
 
244
188
  result.filesChanged += phase1Result.filesChanged;
245
189
  result.directoriesChanged += phase1Result.directoriesChanged;
@@ -248,24 +192,22 @@ export class SyncEngine {
248
192
 
249
193
  // Always wait for network sync when enabled (not just when local changes exist)
250
194
  // This is critical for clone scenarios where we need to pull remote changes
251
- const t5 = Date.now();
252
- if (!dryRun && this.networkSyncEnabled) {
195
+ if (this.config.sync_enabled) {
253
196
  try {
254
197
  // If we have a root directory URL, wait for it to sync
255
198
  if (snapshot.rootDirectoryUrl) {
199
+ const rootDirUrl = snapshot.rootDirectoryUrl;
256
200
  const rootHandle = await this.repo.find<DirectoryDocument>(
257
- snapshot.rootDirectoryUrl
201
+ rootDirUrl
258
202
  );
259
203
  this.handlesToWaitOn.push(rootHandle);
260
204
  }
261
205
 
262
206
  if (this.handlesToWaitOn.length > 0) {
263
- const tWaitStart = Date.now();
264
207
  await waitForSync(
265
208
  this.handlesToWaitOn,
266
- getSyncServerStorageId(this.syncServerStorageId)
209
+ this.config.sync_server_storage_id
267
210
  );
268
- timings["network_sync"] = Date.now() - tWaitStart;
269
211
 
270
212
  // CRITICAL: Wait a bit after our changes reach the server to allow
271
213
  // time for WebSocket to deliver OTHER peers' changes to us.
@@ -276,38 +218,32 @@ export class SyncEngine {
276
218
  // each other due to timing races.
277
219
  //
278
220
  // Optimization: Only wait if we pushed changes (shorter delay if no changes)
279
- const tDelayStart = Date.now();
280
- const delayMs = phase1Result.filesChanged > 0 ? 200 : 100;
281
- await new Promise((resolve) => setTimeout(resolve, delayMs));
282
- timings["post_sync_delay"] = Date.now() - tDelayStart;
221
+
222
+ await new Promise((resolve) =>
223
+ setTimeout(resolve, POST_SYNC_DELAY_MS)
224
+ );
283
225
  }
284
226
  } catch (error) {
285
- console.error(`❌ Network sync failed: ${error}`);
227
+ out.taskLine(`Network sync failed: ${error}`, true);
286
228
  result.warnings.push(`Network sync failed: ${error}`);
287
229
  }
288
230
  }
289
- timings["total_network"] = Date.now() - t5;
290
231
 
291
232
  // Re-detect remote changes after network sync to ensure fresh state
292
233
  // This fixes race conditions where we detect changes before server propagation
293
234
  // NOTE: We DON'T update snapshot heads yet - that would prevent detecting remote changes!
294
- const t6 = Date.now();
295
235
  const freshChanges = await this.changeDetector.detectChanges(snapshot);
296
236
  const freshRemoteChanges = freshChanges.filter(
297
237
  (c) =>
298
238
  c.changeType === ChangeType.REMOTE_ONLY ||
299
239
  c.changeType === ChangeType.BOTH_CHANGED
300
240
  );
301
- timings["redetect_changes"] = Date.now() - t6;
302
241
 
303
242
  // Phase 2: Pull remote changes to local using fresh detection
304
- const t7 = Date.now();
305
243
  const phase2Result = await this.pullRemoteChanges(
306
244
  freshRemoteChanges,
307
- snapshot,
308
- dryRun
245
+ snapshot
309
246
  );
310
- timings["phase2_pull"] = Date.now() - t7;
311
247
  result.filesChanged += phase2Result.filesChanged;
312
248
  result.directoriesChanged += phase2Result.directoriesChanged;
313
249
  result.errors.push(...phase2Result.errors);
@@ -316,80 +252,49 @@ export class SyncEngine {
316
252
  // CRITICAL FIX: Update snapshot heads AFTER pulling remote changes
317
253
  // This ensures that change detection can find remote changes, and we only
318
254
  // update the snapshot after the filesystem is in sync with the documents
319
- const t8 = Date.now();
320
- if (!dryRun) {
321
- // Update file document heads
322
- for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
323
- try {
324
- const handle = await this.repo.find(snapshotEntry.url);
325
- const currentHeads = handle.heads();
326
- if (!A.equals(currentHeads, snapshotEntry.head)) {
327
- // Update snapshot with current heads after pulling changes
328
- snapshot.files.set(filePath, {
329
- ...snapshotEntry,
330
- head: currentHeads,
331
- });
332
- }
333
- } catch (error) {
334
- // Handle might not exist if file was deleted, skip
335
- console.warn(`Could not update heads for ${filePath}: ${error}`);
255
+ // Update file document heads
256
+ for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
257
+ try {
258
+ const handle = await this.repo.find(snapshotEntry.url);
259
+ const currentHeads = handle.heads();
260
+ if (!A.equals(currentHeads, snapshotEntry.head)) {
261
+ // Update snapshot with current heads after pulling changes
262
+ snapshot.files.set(filePath, {
263
+ ...snapshotEntry,
264
+ head: currentHeads,
265
+ });
336
266
  }
267
+ } catch (error) {
268
+ // Handle might not exist if file was deleted
337
269
  }
270
+ }
338
271
 
339
- // Update directory document heads
340
- for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
341
- try {
342
- const handle = await this.repo.find(snapshotEntry.url);
343
- const currentHeads = handle.heads();
344
- if (!A.equals(currentHeads, snapshotEntry.head)) {
345
- // Update snapshot with current heads after pulling changes
346
- snapshot.directories.set(dirPath, {
347
- ...snapshotEntry,
348
- head: currentHeads,
349
- });
350
- }
351
- } catch (error) {
352
- // Handle might not exist if directory was deleted, skip
353
- console.warn(
354
- `Could not update heads for directory ${dirPath}: ${error}`
355
- );
272
+ // Update directory document heads
273
+ for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
274
+ try {
275
+ const handle = await this.repo.find(snapshotEntry.url);
276
+ const currentHeads = handle.heads();
277
+ if (!A.equals(currentHeads, snapshotEntry.head)) {
278
+ // Update snapshot with current heads after pulling changes
279
+ snapshot.directories.set(dirPath, {
280
+ ...snapshotEntry,
281
+ head: currentHeads,
282
+ });
356
283
  }
284
+ } catch (error) {
285
+ // Handle might not exist if directory was deleted
357
286
  }
358
287
  }
359
- timings["update_snapshot_heads"] = Date.now() - t8;
360
288
 
361
289
  // Touch root directory if any changes were made during sync
362
- const t9 = Date.now();
363
290
  const hasChanges =
364
291
  result.filesChanged > 0 || result.directoriesChanged > 0;
365
292
  if (hasChanges) {
366
- await this.touchRootDirectory(snapshot, dryRun);
293
+ await this.touchRootDirectory(snapshot);
367
294
  }
368
- timings["touch_root"] = Date.now() - t9;
369
295
 
370
296
  // Save updated snapshot if not dry run
371
- const t10 = Date.now();
372
- if (!dryRun) {
373
- await this.snapshotManager.save(snapshot);
374
- }
375
- timings["save_snapshot"] = Date.now() - t10;
376
-
377
- // Output timing breakdown if enabled via environment variable
378
- if (process.env.PUSHWORK_TIMING === "1") {
379
- const totalTime = Date.now() - syncStartTime;
380
- console.error("\n⏱️ Sync Timing Breakdown:");
381
- for (const [key, ms] of Object.entries(timings)) {
382
- const pct = ((ms / totalTime) * 100).toFixed(1);
383
- console.error(
384
- ` ${key.padEnd(25)} ${ms.toString().padStart(5)}ms (${pct}%)`
385
- );
386
- }
387
- console.error(
388
- ` ${"TOTAL".padEnd(25)} ${totalTime
389
- .toString()
390
- .padStart(5)}ms (100.0%)\n`
391
- );
392
- }
297
+ await this.snapshotManager.save(snapshot);
393
298
 
394
299
  result.success = result.errors.length === 0;
395
300
  return result;
@@ -410,8 +315,7 @@ export class SyncEngine {
410
315
  private async pushLocalChanges(
411
316
  changes: DetectedChange[],
412
317
  moves: MoveCandidate[],
413
- snapshot: SyncSnapshot,
414
- dryRun: boolean
318
+ snapshot: SyncSnapshot
415
319
  ): Promise<SyncResult> {
416
320
  const result: SyncResult = {
417
321
  success: true,
@@ -421,28 +325,18 @@ export class SyncEngine {
421
325
  warnings: [],
422
326
  };
423
327
 
424
- // Process moves first
328
+ // Process moves first - all detected moves are applied
425
329
  for (const move of moves) {
426
- if (this.moveDetector.shouldAutoApply(move)) {
427
- try {
428
- await this.applyMoveToRemote(move, snapshot, dryRun);
429
- result.filesChanged++;
430
- } catch (error) {
431
- result.errors.push({
432
- path: move.fromPath,
433
- operation: "move",
434
- error: error as Error,
435
- recoverable: true,
436
- });
437
- }
438
- } else if (this.moveDetector.shouldPromptUser(move)) {
439
- // Instead of creating a persistent loop, perform delete+create semantics
440
- // so the working tree converges even without auto-apply.
441
- result.warnings.push(
442
- `Potential move detected: ${this.moveDetector.formatMove(
443
- move
444
- )} (${Math.round(move.similarity * 100)}% similar)`
445
- );
330
+ try {
331
+ await this.applyMoveToRemote(move, snapshot);
332
+ result.filesChanged++;
333
+ } catch (error) {
334
+ result.errors.push({
335
+ path: move.fromPath,
336
+ operation: "move",
337
+ error: error as Error,
338
+ recoverable: true,
339
+ });
446
340
  }
447
341
  }
448
342
 
@@ -455,7 +349,7 @@ export class SyncEngine {
455
349
 
456
350
  for (const change of localChanges) {
457
351
  try {
458
- await this.applyLocalChangeToRemote(change, snapshot, dryRun);
352
+ await this.applyLocalChangeToRemote(change, snapshot);
459
353
  result.filesChanged++;
460
354
  } catch (error) {
461
355
  result.errors.push({
@@ -475,8 +369,7 @@ export class SyncEngine {
475
369
  */
476
370
  private async pullRemoteChanges(
477
371
  changes: DetectedChange[],
478
- snapshot: SyncSnapshot,
479
- dryRun: boolean
372
+ snapshot: SyncSnapshot
480
373
  ): Promise<SyncResult> {
481
374
  const result: SyncResult = {
482
375
  success: true,
@@ -498,7 +391,7 @@ export class SyncEngine {
498
391
 
499
392
  for (const change of sortedChanges) {
500
393
  try {
501
- await this.applyRemoteChangeToLocal(change, snapshot, dryRun);
394
+ await this.applyRemoteChangeToLocal(change, snapshot);
502
395
  result.filesChanged++;
503
396
  } catch (error) {
504
397
  result.errors.push({
@@ -518,8 +411,7 @@ export class SyncEngine {
518
411
  */
519
412
  private async applyLocalChangeToRemote(
520
413
  change: DetectedChange,
521
- snapshot: SyncSnapshot,
522
- dryRun: boolean
414
+ snapshot: SyncSnapshot
523
415
  ): Promise<void> {
524
416
  const snapshotEntry = snapshot.files.get(change.path);
525
417
 
@@ -528,38 +420,24 @@ export class SyncEngine {
528
420
  if (change.localContent === null) {
529
421
  // File was deleted locally
530
422
  if (snapshotEntry) {
531
- console.log(`🗑️ ${change.path}`);
532
- await this.deleteRemoteFile(
533
- snapshotEntry.url,
534
- dryRun,
535
- snapshot,
536
- change.path
537
- );
423
+ await this.deleteRemoteFile(snapshotEntry.url, snapshot, change.path);
538
424
  // Remove from directory document
539
- await this.removeFileFromDirectory(snapshot, change.path, dryRun);
540
- if (!dryRun) {
541
- this.snapshotManager.removeFileEntry(snapshot, change.path);
542
- }
425
+ await this.removeFileFromDirectory(snapshot, change.path);
426
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
543
427
  }
544
428
  return;
545
429
  }
546
430
 
547
431
  if (!snapshotEntry) {
548
432
  // New file
549
- console.log(`➕ ${change.path}`);
550
- const handle = await this.createRemoteFile(change, dryRun);
551
- if (!dryRun && handle) {
552
- await this.addFileToDirectory(
553
- snapshot,
554
- change.path,
555
- handle.url,
556
- dryRun
557
- );
433
+ const handle = await this.createRemoteFile(change);
434
+ if (handle) {
435
+ await this.addFileToDirectory(snapshot, change.path, handle.url);
558
436
 
559
437
  // CRITICAL FIX: Update snapshot with heads AFTER adding to directory
560
438
  // The addFileToDirectory call above may have changed the document heads
561
439
  this.snapshotManager.updateFileEntry(snapshot, change.path, {
562
- path: normalizePath(this.rootPath + "/" + change.path),
440
+ path: joinAndNormalizePath(this.rootPath, change.path),
563
441
  url: handle.url,
564
442
  head: handle.heads(),
565
443
  extension: getFileExtension(change.path),
@@ -568,12 +446,9 @@ export class SyncEngine {
568
446
  }
569
447
  } else {
570
448
  // Update existing file
571
- console.log(`📝 ${change.path}`);
572
-
573
449
  await this.updateRemoteFile(
574
450
  snapshotEntry.url,
575
451
  change.localContent,
576
- dryRun,
577
452
  snapshot,
578
453
  change.path
579
454
  );
@@ -585,10 +460,9 @@ export class SyncEngine {
585
460
  */
586
461
  private async applyRemoteChangeToLocal(
587
462
  change: DetectedChange,
588
- snapshot: SyncSnapshot,
589
- dryRun: boolean
463
+ snapshot: SyncSnapshot
590
464
  ): Promise<void> {
591
- const localPath = normalizePath(this.rootPath + "/" + change.path);
465
+ const localPath = joinAndNormalizePath(this.rootPath, change.path);
592
466
 
593
467
  if (!change.remoteHead) {
594
468
  throw new Error(
@@ -600,53 +474,45 @@ export class SyncEngine {
600
474
  // Empty strings "" and empty Uint8Array are valid file content!
601
475
  if (change.remoteContent === null) {
602
476
  // File was deleted remotely
603
- console.log(`🗑️ ${change.path}`);
604
- if (!dryRun) {
605
- await removePath(localPath);
606
- this.snapshotManager.removeFileEntry(snapshot, change.path);
607
- }
477
+ await removePath(localPath);
478
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
608
479
  return;
609
480
  }
610
481
 
611
482
  // Create or update local file
612
- if (change.changeType === ChangeType.REMOTE_ONLY) {
613
- console.log(`⬇️ ${change.path}`);
614
- } else {
615
- console.log(`🔀 ${change.path}`);
616
- }
483
+ await writeFileContent(localPath, change.remoteContent);
617
484
 
618
- if (!dryRun) {
619
- await writeFileContent(localPath, change.remoteContent);
620
-
621
- // Update or create snapshot entry for this file
622
- const snapshotEntry = snapshot.files.get(change.path);
623
- if (snapshotEntry) {
624
- // Update existing entry
625
- snapshotEntry.head = change.remoteHead;
626
- } else {
627
- // Create new snapshot entry for newly discovered remote file
628
- // We need to find the remote file's URL from the directory hierarchy
629
- if (snapshot.rootDirectoryUrl) {
630
- try {
631
- const fileEntry = await this.findFileInDirectoryHierarchy(
632
- snapshot.rootDirectoryUrl,
633
- change.path
634
- );
485
+ // Update or create snapshot entry for this file
486
+ const snapshotEntry = snapshot.files.get(change.path);
487
+ if (snapshotEntry) {
488
+ // Update existing entry
489
+ snapshotEntry.head = change.remoteHead;
490
+ } else {
491
+ // Create new snapshot entry for newly discovered remote file
492
+ // We need to find the remote file's URL from the directory hierarchy
493
+ if (snapshot.rootDirectoryUrl) {
494
+ try {
495
+ const fileEntry = await findFileInDirectoryHierarchy(
496
+ this.repo,
497
+ snapshot.rootDirectoryUrl,
498
+ change.path
499
+ );
635
500
 
636
- if (fileEntry) {
637
- this.snapshotManager.updateFileEntry(snapshot, change.path, {
638
- path: localPath,
639
- url: fileEntry.url,
640
- head: change.remoteHead,
641
- extension: getFileExtension(change.path),
642
- mimeType: getEnhancedMimeType(change.path),
643
- });
644
- }
645
- } catch (error) {
646
- console.warn(
647
- `Failed to update snapshot for remote file ${change.path}: ${error}`
648
- );
501
+ if (fileEntry) {
502
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
503
+ path: localPath,
504
+ url: fileEntry.url,
505
+ head: change.remoteHead,
506
+ extension: getFileExtension(change.path),
507
+ mimeType: getEnhancedMimeType(change.path),
508
+ });
649
509
  }
510
+ } catch (error) {
511
+ // Failed to update snapshot - file may have been deleted
512
+ out.taskLine(
513
+ `Warning: Failed to update snapshot for remote file ${change.path}`,
514
+ true
515
+ );
650
516
  }
651
517
  }
652
518
  }
@@ -657,103 +523,86 @@ export class SyncEngine {
657
523
  */
658
524
  private async applyMoveToRemote(
659
525
  move: MoveCandidate,
660
- snapshot: SyncSnapshot,
661
- dryRun: boolean
526
+ snapshot: SyncSnapshot
662
527
  ): Promise<void> {
663
528
  const fromEntry = snapshot.files.get(move.fromPath);
664
529
  if (!fromEntry) return;
665
530
 
666
531
  // Parse paths
667
- const fromParts = move.fromPath.split("/");
668
- const fromFileName = fromParts.pop() || "";
669
- const fromDirPath = fromParts.join("/");
670
-
671
532
  const toParts = move.toPath.split("/");
672
533
  const toFileName = toParts.pop() || "";
673
534
  const toDirPath = toParts.join("/");
674
535
 
675
- if (!dryRun) {
676
- // 1) Remove file entry from old directory document
677
- if (move.fromPath !== move.toPath) {
678
- await this.removeFileFromDirectory(snapshot, move.fromPath, dryRun);
679
- }
536
+ // 1) Remove file entry from old directory document
537
+ if (move.fromPath !== move.toPath) {
538
+ await this.removeFileFromDirectory(snapshot, move.fromPath);
539
+ }
680
540
 
681
- // 2) Ensure destination directory document exists and add file entry there
682
- const destDirUrl = await this.ensureDirectoryDocument(
683
- snapshot,
684
- toDirPath,
685
- dryRun
686
- );
687
- await this.addFileToDirectory(
688
- snapshot,
689
- move.toPath,
690
- fromEntry.url,
691
- dryRun
692
- );
541
+ // 2) Ensure destination directory document exists and add file entry there
542
+ await this.ensureDirectoryDocument(snapshot, toDirPath);
543
+ await this.addFileToDirectory(snapshot, move.toPath, fromEntry.url);
693
544
 
694
- // 3) Update the FileDocument name and content to match new location/state
695
- try {
696
- const handle = await this.repo.find<FileDocument>(fromEntry.url);
697
- const heads = fromEntry.head;
698
-
699
- // Update both name and content (if content changed during move)
700
- if (heads && heads.length > 0) {
701
- handle.changeAt(heads, (doc: FileDocument) => {
702
- doc.name = toFileName;
703
-
704
- // If new content is provided, update it (handles move + modification case)
705
- if (move.newContent !== undefined) {
706
- const isText = this.isTextContent(move.newContent);
707
- if (isText && typeof move.newContent === "string") {
708
- updateText(doc, ["content"], move.newContent);
709
- } else {
710
- doc.content = move.newContent;
711
- }
545
+ // 3) Update the FileDocument name and content to match new location/state
546
+ try {
547
+ const handle = await this.repo.find<FileDocument>(fromEntry.url);
548
+ const heads = fromEntry.head;
549
+
550
+ // Update both name and content (if content changed during move)
551
+ if (heads && heads.length > 0) {
552
+ handle.changeAt(heads, (doc: FileDocument) => {
553
+ doc.name = toFileName;
554
+
555
+ // If new content is provided, update it (handles move + modification case)
556
+ if (move.newContent !== undefined) {
557
+ if (typeof move.newContent === "string") {
558
+ doc.content = new A.ImmutableString(move.newContent);
559
+ } else {
560
+ doc.content = move.newContent;
712
561
  }
713
- });
714
- } else {
715
- handle.change((doc: FileDocument) => {
716
- doc.name = toFileName;
717
-
718
- // If new content is provided, update it (handles move + modification case)
719
- if (move.newContent !== undefined) {
720
- const isText = this.isTextContent(move.newContent);
721
- if (isText && typeof move.newContent === "string") {
722
- updateText(doc, ["content"], move.newContent);
723
- } else {
724
- doc.content = move.newContent;
725
- }
562
+ }
563
+ });
564
+ } else {
565
+ handle.change((doc: FileDocument) => {
566
+ doc.name = toFileName;
567
+
568
+ // If new content is provided, update it (handles move + modification case)
569
+ if (move.newContent !== undefined) {
570
+ if (typeof move.newContent === "string") {
571
+ doc.content = new A.ImmutableString(move.newContent);
572
+ } else {
573
+ doc.content = move.newContent;
726
574
  }
727
- });
728
- }
729
- // Track file handle for network sync
730
- this.handlesToWaitOn.push(handle);
731
- } catch (e) {
732
- console.warn(
733
- `Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
734
- );
575
+ }
576
+ });
735
577
  }
736
-
737
- // 4) Update snapshot entries
738
- this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
739
- this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
740
- ...fromEntry,
741
- path: normalizePath(this.rootPath + "/" + move.toPath),
742
- head: fromEntry.head, // will be updated later when heads advance
743
- });
578
+ // Track file handle for network sync
579
+ this.handlesToWaitOn.push(handle);
580
+ } catch (e) {
581
+ // Failed to update file name - file may have been deleted
582
+ out.taskLine(
583
+ `Warning: Failed to rename ${move.fromPath} to ${move.toPath}`,
584
+ true
585
+ );
744
586
  }
587
+
588
+ // 4) Update snapshot entries
589
+ this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
590
+ this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
591
+ ...fromEntry,
592
+ path: joinAndNormalizePath(this.rootPath, move.toPath),
593
+ head: fromEntry.head, // will be updated later when heads advance
594
+ });
745
595
  }
746
596
 
747
597
  /**
748
598
  * Create new remote file document
749
599
  */
750
600
  private async createRemoteFile(
751
- change: DetectedChange,
752
- dryRun: boolean
601
+ change: DetectedChange
753
602
  ): Promise<DocHandle<FileDocument> | null> {
754
603
  // CRITICAL: Check for null explicitly, not falsy values
755
604
  // Empty strings "" and empty Uint8Array are valid file content!
756
- if (dryRun || change.localContent === null) return null;
605
+ if (change.localContent === null) return null;
757
606
 
758
607
  const isText = this.isTextContent(change.localContent);
759
608
 
@@ -763,7 +612,11 @@ export class SyncEngine {
763
612
  name: change.path.split("/").pop() || "",
764
613
  extension: getFileExtension(change.path),
765
614
  mimeType: getEnhancedMimeType(change.path),
766
- content: isText ? "" : change.localContent, // Empty string for text, actual content for binary
615
+ content: isText
616
+ ? new A.ImmutableString("")
617
+ : typeof change.localContent === "string"
618
+ ? new A.ImmutableString(change.localContent)
619
+ : change.localContent, // Empty ImmutableString for text, wrap strings for safety, actual content for binary
767
620
  metadata: {
768
621
  permissions: 0o644,
769
622
  },
@@ -771,10 +624,10 @@ export class SyncEngine {
771
624
 
772
625
  const handle = this.repo.create(fileDoc);
773
626
 
774
- // For text files, use updateText to set the content properly
627
+ // For text files, use ImmutableString for better performance
775
628
  if (isText && typeof change.localContent === "string") {
776
629
  handle.change((doc: FileDocument) => {
777
- updateText(doc, ["content"], change.localContent as string);
630
+ doc.content = new A.ImmutableString(change.localContent as string);
778
631
  });
779
632
  }
780
633
 
@@ -791,12 +644,9 @@ export class SyncEngine {
791
644
  private async updateRemoteFile(
792
645
  url: AutomergeUrl,
793
646
  content: string | Uint8Array,
794
- dryRun: boolean,
795
647
  snapshot: SyncSnapshot,
796
648
  filePath: string
797
649
  ): Promise<void> {
798
- if (dryRun) return;
799
-
800
650
  const handle = await this.repo.find<FileDocument>(url);
801
651
 
802
652
  // Check if content actually changed before tracking for sync
@@ -818,9 +668,6 @@ export class SyncEngine {
818
668
  if (!contentChanged) {
819
669
  // Content is identical, but we've updated the snapshot heads above
820
670
  // This prevents fresh change detection from seeing stale heads
821
- console.log(
822
- `🔍 Content is identical, but we've updated the snapshot heads above`
823
- );
824
671
  return;
825
672
  }
826
673
 
@@ -831,16 +678,15 @@ export class SyncEngine {
831
678
  }
832
679
 
833
680
  handle.changeAt(heads, (doc: FileDocument) => {
834
- const isText = this.isTextContent(content);
835
- if (isText && typeof content === "string") {
836
- updateText(doc, ["content"], content);
681
+ if (typeof content === "string") {
682
+ doc.content = new A.ImmutableString(content);
837
683
  } else {
838
684
  doc.content = content;
839
685
  }
840
686
  });
841
687
 
842
688
  // Update snapshot with new heads after content change
843
- if (!dryRun && snapshotEntry) {
689
+ if (snapshotEntry) {
844
690
  snapshot.files.set(filePath, {
845
691
  ...snapshotEntry,
846
692
  head: handle.heads(),
@@ -856,12 +702,9 @@ export class SyncEngine {
856
702
  */
857
703
  private async deleteRemoteFile(
858
704
  url: AutomergeUrl,
859
- dryRun: boolean,
860
705
  snapshot?: SyncSnapshot,
861
706
  filePath?: string
862
707
  ): Promise<void> {
863
- if (dryRun) return;
864
-
865
708
  // In Automerge, we don't actually delete documents
866
709
  // They become orphaned and will be garbage collected
867
710
  // For now, we just mark them as deleted by clearing content
@@ -873,11 +716,11 @@ export class SyncEngine {
873
716
  }
874
717
  if (heads) {
875
718
  handle.changeAt(heads, (doc: FileDocument) => {
876
- doc.content = "";
719
+ doc.content = new A.ImmutableString("");
877
720
  });
878
721
  } else {
879
722
  handle.change((doc: FileDocument) => {
880
- doc.content = "";
723
+ doc.content = new A.ImmutableString("");
881
724
  });
882
725
  }
883
726
  }
@@ -888,10 +731,9 @@ export class SyncEngine {
888
731
  private async addFileToDirectory(
889
732
  snapshot: SyncSnapshot,
890
733
  filePath: string,
891
- fileUrl: AutomergeUrl,
892
- dryRun: boolean
734
+ fileUrl: AutomergeUrl
893
735
  ): Promise<void> {
894
- if (dryRun || !snapshot.rootDirectoryUrl) return;
736
+ if (!snapshot.rootDirectoryUrl) return;
895
737
 
896
738
  const pathParts = filePath.split("/");
897
739
  const fileName = pathParts.pop() || "";
@@ -900,12 +742,7 @@ export class SyncEngine {
900
742
  // Get or create the parent directory document
901
743
  const parentDirUrl = await this.ensureDirectoryDocument(
902
744
  snapshot,
903
- directoryPath,
904
- dryRun
905
- );
906
-
907
- console.log(
908
- `🔗 Adding ${fileName} (${fileUrl}) to directory ${parentDirUrl} (path: ${directoryPath})`
745
+ directoryPath
909
746
  );
910
747
 
911
748
  const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
@@ -959,8 +796,7 @@ export class SyncEngine {
959
796
  */
960
797
  private async ensureDirectoryDocument(
961
798
  snapshot: SyncSnapshot,
962
- directoryPath: string,
963
- dryRun: boolean
799
+ directoryPath: string
964
800
  ): Promise<AutomergeUrl> {
965
801
  // Root directory case
966
802
  if (!directoryPath || directoryPath === "") {
@@ -981,8 +817,7 @@ export class SyncEngine {
981
817
  // Ensure parent directory exists first (recursive)
982
818
  const parentDirUrl = await this.ensureDirectoryDocument(
983
819
  snapshot,
984
- parentPath,
985
- dryRun
820
+ parentPath
986
821
  );
987
822
 
988
823
  // DISCOVERY: Check if directory already exists in parent on server
@@ -1008,32 +843,21 @@ export class SyncEngine {
1008
843
  const childHeads = childDirHandle.heads();
1009
844
 
1010
845
  // Update snapshot with discovered directory using validated heads
1011
- if (!dryRun) {
1012
- this.snapshotManager.updateDirectoryEntry(
1013
- snapshot,
1014
- directoryPath,
1015
- {
1016
- path: normalizePath(this.rootPath + "/" + directoryPath),
1017
- url: existingDirEntry.url,
1018
- head: childHeads,
1019
- entries: [],
1020
- }
1021
- );
1022
- }
846
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
847
+ path: joinAndNormalizePath(this.rootPath, directoryPath),
848
+ url: existingDirEntry.url,
849
+ head: childHeads,
850
+ entries: [],
851
+ });
1023
852
 
1024
853
  return existingDirEntry.url;
1025
854
  } catch (resolveErr) {
1026
- console.warn(
1027
- `Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
1028
- );
1029
- // Fall through to create a fresh directory document
855
+ // Failed to resolve directory - fall through to create a fresh directory document
1030
856
  }
1031
857
  }
1032
858
  }
1033
859
  } catch (error) {
1034
- console.warn(
1035
- `Failed to check for existing directory ${currentDirName}: ${error}`
1036
- );
860
+ // Failed to check for existing directory - will create new one
1037
861
  }
1038
862
 
1039
863
  // CREATE: Directory doesn't exist, create new one
@@ -1065,28 +889,26 @@ export class SyncEngine {
1065
889
  });
1066
890
 
1067
891
  // Track directory handles for sync
1068
- if (!dryRun) {
1069
- this.handlesToWaitOn.push(dirHandle);
1070
- if (didChange) {
1071
- this.handlesToWaitOn.push(parentHandle);
1072
-
1073
- // CRITICAL FIX: Update parent directory heads in snapshot immediately
1074
- // This prevents stale head issues when parent directory is modified
1075
- const parentSnapshotEntry = snapshot.directories.get(parentPath);
1076
- if (parentSnapshotEntry) {
1077
- parentSnapshotEntry.head = parentHandle.heads();
1078
- }
1079
- }
892
+ this.handlesToWaitOn.push(dirHandle);
893
+ if (didChange) {
894
+ this.handlesToWaitOn.push(parentHandle);
1080
895
 
1081
- // Update snapshot with new directory
1082
- this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1083
- path: normalizePath(this.rootPath + "/" + directoryPath),
1084
- url: dirHandle.url,
1085
- head: dirHandle.heads(),
1086
- entries: [],
1087
- });
896
+ // CRITICAL FIX: Update parent directory heads in snapshot immediately
897
+ // This prevents stale head issues when parent directory is modified
898
+ const parentSnapshotEntry = snapshot.directories.get(parentPath);
899
+ if (parentSnapshotEntry) {
900
+ parentSnapshotEntry.head = parentHandle.heads();
901
+ }
1088
902
  }
1089
903
 
904
+ // Update snapshot with new directory
905
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
906
+ path: joinAndNormalizePath(this.rootPath, directoryPath),
907
+ url: dirHandle.url,
908
+ head: dirHandle.heads(),
909
+ entries: [],
910
+ });
911
+
1090
912
  return dirHandle.url;
1091
913
  }
1092
914
 
@@ -1095,10 +917,9 @@ export class SyncEngine {
1095
917
  */
1096
918
  private async removeFileFromDirectory(
1097
919
  snapshot: SyncSnapshot,
1098
- filePath: string,
1099
- dryRun: boolean
920
+ filePath: string
1100
921
  ): Promise<void> {
1101
- if (dryRun || !snapshot.rootDirectoryUrl) return;
922
+ if (!snapshot.rootDirectoryUrl) return;
1102
923
 
1103
924
  const pathParts = filePath.split("/");
1104
925
  const fileName = pathParts.pop() || "";
@@ -1111,9 +932,7 @@ export class SyncEngine {
1111
932
  } else {
1112
933
  const existingDir = snapshot.directories.get(directoryPath);
1113
934
  if (!existingDir) {
1114
- console.warn(
1115
- `Directory ${directoryPath} not found in snapshot for file removal`
1116
- );
935
+ // Directory not found - file may already be removed
1117
936
  return;
1118
937
  }
1119
938
  parentDirUrl = existingDir.url;
@@ -1136,9 +955,9 @@ export class SyncEngine {
1136
955
  if (indexToRemove !== -1) {
1137
956
  doc.docs.splice(indexToRemove, 1);
1138
957
  didChange = true;
1139
- console.log(
1140
- `🗑️ Removed ${fileName} from directory ${
1141
- directoryPath || "root"
958
+ out.taskLine(
959
+ `Removed ${fileName} from ${
960
+ formatRelativePath(directoryPath) || "root"
1142
961
  }`
1143
962
  );
1144
963
  }
@@ -1151,9 +970,9 @@ export class SyncEngine {
1151
970
  if (indexToRemove !== -1) {
1152
971
  doc.docs.splice(indexToRemove, 1);
1153
972
  didChange = true;
1154
- console.log(
1155
- `🗑️ Removed ${fileName} from directory ${
1156
- directoryPath || "root"
973
+ out.taskLine(
974
+ `Removed ${fileName} from ${
975
+ formatRelativePath(directoryPath) || "root"
1157
976
  }`
1158
977
  );
1159
978
  }
@@ -1166,68 +985,11 @@ export class SyncEngine {
1166
985
  snapshotEntry.head = dirHandle.heads();
1167
986
  }
1168
987
  } catch (error) {
1169
- console.warn(
1170
- `Failed to remove ${fileName} from directory ${
1171
- directoryPath || "root"
1172
- }: ${error}`
1173
- );
988
+ // Failed to remove from directory - re-throw for caller to handle
1174
989
  throw error;
1175
990
  }
1176
991
  }
1177
992
 
1178
- /**
1179
- * Find a file in the directory hierarchy by path
1180
- */
1181
- private async findFileInDirectoryHierarchy(
1182
- directoryUrl: AutomergeUrl,
1183
- filePath: string
1184
- ): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
1185
- try {
1186
- const pathParts = filePath.split("/");
1187
- let currentDirUrl = directoryUrl;
1188
-
1189
- // Navigate through directories to find the parent directory
1190
- for (let i = 0; i < pathParts.length - 1; i++) {
1191
- const dirName = pathParts[i];
1192
- const dirHandle = await this.repo.find<DirectoryDocument>(
1193
- currentDirUrl
1194
- );
1195
- const dirDoc = await dirHandle.doc();
1196
-
1197
- if (!dirDoc) return null;
1198
-
1199
- const subDirEntry = dirDoc.docs.find(
1200
- (entry: { name: string; type: string; url: AutomergeUrl }) =>
1201
- entry.name === dirName && entry.type === "folder"
1202
- );
1203
-
1204
- if (!subDirEntry) return null;
1205
- currentDirUrl = subDirEntry.url;
1206
- }
1207
-
1208
- // Now look for the file in the final directory
1209
- const fileName = pathParts[pathParts.length - 1];
1210
- const finalDirHandle = await this.repo.find<DirectoryDocument>(
1211
- currentDirUrl
1212
- );
1213
- const finalDirDoc = await finalDirHandle.doc();
1214
-
1215
- if (!finalDirDoc) return null;
1216
-
1217
- const fileEntry = finalDirDoc.docs.find(
1218
- (entry: { name: string; type: string; url: AutomergeUrl }) =>
1219
- entry.name === fileName && entry.type === "file"
1220
- );
1221
-
1222
- return fileEntry || null;
1223
- } catch (error) {
1224
- console.warn(
1225
- `Failed to find file ${filePath} in directory hierarchy: ${error}`
1226
- );
1227
- return null;
1228
- }
1229
- }
1230
-
1231
993
  /**
1232
994
  * Sort changes by dependency order
1233
995
  */
@@ -1288,11 +1050,7 @@ export class SyncEngine {
1288
1050
  }
1289
1051
 
1290
1052
  const changes = await this.changeDetector.detectChanges(snapshot);
1291
- const { moves } = await this.moveDetector.detectMoves(
1292
- changes,
1293
- snapshot,
1294
- this.rootPath
1295
- );
1053
+ const { moves } = await this.moveDetector.detectMoves(changes, snapshot);
1296
1054
 
1297
1055
  const summary = this.generateChangeSummary(changes, moves);
1298
1056
 
@@ -1354,11 +1112,8 @@ export class SyncEngine {
1354
1112
  /**
1355
1113
  * Update the lastSyncAt timestamp on the root directory document
1356
1114
  */
1357
- private async touchRootDirectory(
1358
- snapshot: SyncSnapshot,
1359
- dryRun: boolean
1360
- ): Promise<void> {
1361
- if (dryRun || !snapshot.rootDirectoryUrl) {
1115
+ private async touchRootDirectory(snapshot: SyncSnapshot): Promise<void> {
1116
+ if (!snapshot.rootDirectoryUrl) {
1362
1117
  return;
1363
1118
  }
1364
1119
 
@@ -1390,14 +1145,8 @@ export class SyncEngine {
1390
1145
  if (snapshotEntry) {
1391
1146
  snapshotEntry.head = rootHandle.heads();
1392
1147
  }
1393
-
1394
- console.log(
1395
- `🕒 Updated root directory lastSyncAt to ${new Date(
1396
- timestamp
1397
- ).toISOString()}`
1398
- );
1399
1148
  } catch (error) {
1400
- console.warn(`Failed to update root directory lastSyncAt: ${error}`);
1149
+ // Failed to update root directory timestamp
1401
1150
  }
1402
1151
  }
1403
1152
  }