pushwork 1.0.5 → 1.0.11

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 (196) hide show
  1. package/README.md +87 -335
  2. package/babel.config.js +5 -0
  3. package/dist/cli/commands.d.ts +9 -15
  4. package/dist/cli/commands.d.ts.map +1 -1
  5. package/dist/cli/commands.js +37 -170
  6. package/dist/cli/commands.js.map +1 -1
  7. package/dist/cli/output.d.ts +11 -25
  8. package/dist/cli/output.d.ts.map +1 -1
  9. package/dist/cli/output.js +55 -61
  10. package/dist/cli/output.js.map +1 -1
  11. package/dist/cli.js +208 -213
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands.d.ts +51 -0
  14. package/dist/commands.d.ts.map +1 -0
  15. package/dist/commands.js +799 -0
  16. package/dist/commands.js.map +1 -0
  17. package/dist/core/change-detection.d.ts +7 -23
  18. package/dist/core/change-detection.d.ts.map +1 -1
  19. package/dist/core/change-detection.js +108 -122
  20. package/dist/core/change-detection.js.map +1 -1
  21. package/dist/core/config.d.ts +81 -0
  22. package/dist/core/config.d.ts.map +1 -0
  23. package/dist/core/config.js +296 -0
  24. package/dist/core/config.js.map +1 -0
  25. package/dist/core/index.d.ts +1 -0
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/move-detection.d.ts +4 -3
  30. package/dist/core/move-detection.d.ts.map +1 -1
  31. package/dist/core/move-detection.js +8 -7
  32. package/dist/core/move-detection.js.map +1 -1
  33. package/dist/core/snapshot.d.ts +0 -4
  34. package/dist/core/snapshot.d.ts.map +1 -1
  35. package/dist/core/snapshot.js +2 -11
  36. package/dist/core/snapshot.js.map +1 -1
  37. package/dist/core/sync-engine.d.ts +41 -12
  38. package/dist/core/sync-engine.d.ts.map +1 -1
  39. package/dist/core/sync-engine.js +522 -359
  40. package/dist/core/sync-engine.js.map +1 -1
  41. package/dist/index.d.ts +0 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +0 -6
  44. package/dist/index.js.map +1 -1
  45. package/dist/types/config.d.ts +24 -88
  46. package/dist/types/config.d.ts.map +1 -1
  47. package/dist/types/config.js +6 -0
  48. package/dist/types/config.js.map +1 -1
  49. package/dist/types/documents.d.ts +15 -2
  50. package/dist/types/documents.d.ts.map +1 -1
  51. package/dist/types/documents.js.map +1 -1
  52. package/dist/types/index.d.ts.map +1 -1
  53. package/dist/types/index.js +0 -3
  54. package/dist/types/index.js.map +1 -1
  55. package/dist/types/snapshot.d.ts +0 -21
  56. package/dist/types/snapshot.d.ts.map +1 -1
  57. package/dist/types/snapshot.js +0 -14
  58. package/dist/types/snapshot.js.map +1 -1
  59. package/dist/utils/content.d.ts.map +1 -1
  60. package/dist/utils/content.js +2 -6
  61. package/dist/utils/content.js.map +1 -1
  62. package/dist/utils/directory.d.ts +24 -0
  63. package/dist/utils/directory.d.ts.map +1 -0
  64. package/dist/utils/directory.js +56 -0
  65. package/dist/utils/directory.js.map +1 -0
  66. package/dist/utils/fs.d.ts +15 -2
  67. package/dist/utils/fs.d.ts.map +1 -1
  68. package/dist/utils/fs.js +53 -20
  69. package/dist/utils/fs.js.map +1 -1
  70. package/dist/utils/index.d.ts +1 -0
  71. package/dist/utils/index.d.ts.map +1 -1
  72. package/dist/utils/index.js +1 -3
  73. package/dist/utils/index.js.map +1 -1
  74. package/dist/utils/keyhive.d.ts +9 -0
  75. package/dist/utils/keyhive.d.ts.map +1 -0
  76. package/dist/utils/keyhive.js +26 -0
  77. package/dist/utils/keyhive.js.map +1 -0
  78. package/dist/utils/mime-types.d.ts.map +1 -1
  79. package/dist/utils/mime-types.js +11 -4
  80. package/dist/utils/mime-types.js.map +1 -1
  81. package/dist/utils/network-sync.d.ts +16 -7
  82. package/dist/utils/network-sync.d.ts.map +1 -1
  83. package/dist/utils/network-sync.js +158 -99
  84. package/dist/utils/network-sync.js.map +1 -1
  85. package/dist/utils/output.d.ts +129 -0
  86. package/dist/utils/output.d.ts.map +1 -0
  87. package/dist/utils/output.js +375 -0
  88. package/dist/utils/output.js.map +1 -0
  89. package/dist/utils/repo-factory.d.ts +2 -6
  90. package/dist/utils/repo-factory.d.ts.map +1 -1
  91. package/dist/utils/repo-factory.js +8 -31
  92. package/dist/utils/repo-factory.js.map +1 -1
  93. package/dist/utils/string-similarity.js +2 -2
  94. package/dist/utils/string-similarity.js.map +1 -1
  95. package/dist/utils/trace.d.ts +19 -0
  96. package/dist/utils/trace.d.ts.map +1 -0
  97. package/dist/utils/trace.js +68 -0
  98. package/dist/utils/trace.js.map +1 -0
  99. package/package.json +21 -11
  100. package/src/cli.ts +276 -308
  101. package/src/commands.ts +988 -0
  102. package/src/core/change-detection.ts +226 -246
  103. package/src/{config/index.ts → core/config.ts} +65 -82
  104. package/src/core/index.ts +1 -1
  105. package/src/core/move-detection.ts +10 -8
  106. package/src/core/snapshot.ts +2 -12
  107. package/src/core/sync-engine.ts +630 -478
  108. package/src/index.ts +0 -10
  109. package/src/types/config.ts +28 -93
  110. package/src/types/documents.ts +16 -2
  111. package/src/types/index.ts +0 -5
  112. package/src/types/snapshot.ts +0 -23
  113. package/src/utils/content.ts +2 -6
  114. package/src/utils/directory.ts +73 -0
  115. package/src/utils/fs.ts +57 -23
  116. package/src/utils/index.ts +1 -5
  117. package/src/utils/mime-types.ts +12 -4
  118. package/src/utils/network-sync.ts +216 -138
  119. package/src/utils/output.ts +450 -0
  120. package/src/utils/repo-factory.ts +13 -44
  121. package/src/utils/string-similarity.ts +2 -2
  122. package/src/utils/trace.ts +70 -0
  123. package/test/integration/exclude-patterns.test.ts +6 -15
  124. package/test/integration/fuzzer.test.ts +308 -391
  125. package/test/integration/in-memory-sync.test.ts +435 -0
  126. package/test/integration/init-sync.test.ts +89 -0
  127. package/test/integration/sync-deletion.test.ts +2 -61
  128. package/test/integration/sync-flow.test.ts +4 -24
  129. package/test/jest.setup.ts +34 -0
  130. package/test/unit/deletion-behavior.test.ts +3 -14
  131. package/test/unit/enhanced-mime-detection.test.ts +0 -22
  132. package/test/unit/snapshot.test.ts +2 -29
  133. package/test/unit/sync-convergence.test.ts +3 -198
  134. package/test/unit/sync-timing.test.ts +0 -44
  135. package/test/unit/utils.test.ts +0 -2
  136. package/tsconfig.json +3 -3
  137. package/bench/filesystem.bench.ts +0 -78
  138. package/bench/hashing.bench.ts +0 -60
  139. package/bench/move-detection.bench.ts +0 -130
  140. package/bench/runner.ts +0 -49
  141. package/dist/browser/browser-sync-engine.d.ts +0 -64
  142. package/dist/browser/browser-sync-engine.d.ts.map +0 -1
  143. package/dist/browser/browser-sync-engine.js +0 -303
  144. package/dist/browser/browser-sync-engine.js.map +0 -1
  145. package/dist/browser/filesystem-adapter.d.ts +0 -84
  146. package/dist/browser/filesystem-adapter.d.ts.map +0 -1
  147. package/dist/browser/filesystem-adapter.js +0 -413
  148. package/dist/browser/filesystem-adapter.js.map +0 -1
  149. package/dist/browser/index.d.ts +0 -36
  150. package/dist/browser/index.d.ts.map +0 -1
  151. package/dist/browser/index.js +0 -90
  152. package/dist/browser/index.js.map +0 -1
  153. package/dist/browser/types.d.ts +0 -70
  154. package/dist/browser/types.d.ts.map +0 -1
  155. package/dist/browser/types.js +0 -6
  156. package/dist/browser/types.js.map +0 -1
  157. package/dist/config/remote-manager.d.ts +0 -65
  158. package/dist/config/remote-manager.d.ts.map +0 -1
  159. package/dist/config/remote-manager.js +0 -243
  160. package/dist/config/remote-manager.js.map +0 -1
  161. package/dist/core/isomorphic-snapshot.d.ts +0 -58
  162. package/dist/core/isomorphic-snapshot.d.ts.map +0 -1
  163. package/dist/core/isomorphic-snapshot.js +0 -204
  164. package/dist/core/isomorphic-snapshot.js.map +0 -1
  165. package/dist/platform/browser-filesystem.d.ts +0 -26
  166. package/dist/platform/browser-filesystem.d.ts.map +0 -1
  167. package/dist/platform/browser-filesystem.js +0 -91
  168. package/dist/platform/browser-filesystem.js.map +0 -1
  169. package/dist/platform/filesystem.d.ts +0 -29
  170. package/dist/platform/filesystem.d.ts.map +0 -1
  171. package/dist/platform/filesystem.js +0 -65
  172. package/dist/platform/filesystem.js.map +0 -1
  173. package/dist/platform/node-filesystem.d.ts +0 -21
  174. package/dist/platform/node-filesystem.d.ts.map +0 -1
  175. package/dist/platform/node-filesystem.js +0 -93
  176. package/dist/platform/node-filesystem.js.map +0 -1
  177. package/dist/utils/fs-browser.d.ts +0 -57
  178. package/dist/utils/fs-browser.d.ts.map +0 -1
  179. package/dist/utils/fs-browser.js +0 -311
  180. package/dist/utils/fs-browser.js.map +0 -1
  181. package/dist/utils/fs-node.d.ts +0 -53
  182. package/dist/utils/fs-node.d.ts.map +0 -1
  183. package/dist/utils/fs-node.js +0 -220
  184. package/dist/utils/fs-node.js.map +0 -1
  185. package/dist/utils/isomorphic.d.ts +0 -29
  186. package/dist/utils/isomorphic.d.ts.map +0 -1
  187. package/dist/utils/isomorphic.js +0 -139
  188. package/dist/utils/isomorphic.js.map +0 -1
  189. package/dist/utils/pure.d.ts +0 -25
  190. package/dist/utils/pure.d.ts.map +0 -1
  191. package/dist/utils/pure.js +0 -112
  192. package/dist/utils/pure.js.map +0 -1
  193. package/src/cli/commands.ts +0 -1030
  194. package/src/cli/index.ts +0 -2
  195. package/src/cli/output.ts +0 -244
  196. package/test/README-TESTING-GAPS.md +0 -174
@@ -1,40 +1,42 @@
1
1
  import {
2
2
  AutomergeUrl,
3
3
  Repo,
4
- updateText,
5
4
  DocHandle,
6
- UrlHeads,
5
+ parseAutomergeUrl,
6
+ stringifyAutomergeUrl,
7
7
  } from "@automerge/automerge-repo";
8
8
  import * as A from "@automerge/automerge";
9
9
  import {
10
10
  SyncSnapshot,
11
11
  SyncResult,
12
- SyncError,
13
- SyncOperation,
14
- PendingSyncOperation,
15
12
  FileDocument,
16
13
  DirectoryDocument,
17
- FileType,
18
14
  ChangeType,
19
15
  MoveCandidate,
16
+ DirectoryConfig,
17
+ DetectedChange,
20
18
  } from "../types";
21
19
  import {
22
- readFileContent,
23
20
  writeFileContent,
24
21
  removePath,
25
- movePath,
26
- ensureDirectoryExists,
27
22
  getFileExtension,
28
- normalizePath,
29
- getRelativePath,
30
23
  getEnhancedMimeType,
31
- isEnhancedTextFile,
24
+ formatRelativePath,
25
+ findFileInDirectoryHierarchy,
26
+ joinAndNormalizePath,
27
+ getPlainUrl,
32
28
  } from "../utils";
33
29
  import { isContentEqual } from "../utils/content";
34
- import { waitForSync, getSyncServerStorageId } from "../utils/network-sync";
30
+ import { waitForSync, waitForBidirectionalSync } from "../utils/network-sync";
35
31
  import { SnapshotManager } from "./snapshot";
36
- import { ChangeDetector, DetectedChange } from "./change-detection";
32
+ import { ChangeDetector } from "./change-detection";
37
33
  import { MoveDetector } from "./move-detection";
34
+ import { out } from "../utils/output";
35
+
36
+ /**
37
+ * Sync configuration constants
38
+ */
39
+ const BIDIRECTIONAL_SYNC_TIMEOUT_MS = 5000; // Timeout for bidirectional sync stability check
38
40
 
39
41
  /**
40
42
  * Bidirectional sync engine implementing two-phase sync
@@ -43,22 +45,24 @@ export class SyncEngine {
43
45
  private snapshotManager: SnapshotManager;
44
46
  private changeDetector: ChangeDetector;
45
47
  private moveDetector: MoveDetector;
46
- private networkSyncEnabled: boolean = true;
47
- private handlesToWaitOn: DocHandle<unknown>[] = [];
48
- private syncServerStorageId?: string;
48
+ // Map from path to handle for leaf-first sync ordering
49
+ // Path depth determines sync order (deepest first)
50
+ private handlesByPath: Map<string, DocHandle<unknown>> = new Map();
51
+ private config: DirectoryConfig;
49
52
 
50
53
  constructor(
51
54
  private repo: Repo,
52
55
  private rootPath: string,
53
- excludePatterns: string[] = [],
54
- networkSyncEnabled: boolean = true,
55
- syncServerStorageId?: string
56
+ config: DirectoryConfig
56
57
  ) {
58
+ this.config = config;
57
59
  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;
60
+ this.changeDetector = new ChangeDetector(
61
+ repo,
62
+ rootPath,
63
+ config.exclude_patterns
64
+ );
65
+ this.moveDetector = new MoveDetector(config.sync.move_detection_threshold);
62
66
  }
63
67
 
64
68
  /**
@@ -71,6 +75,16 @@ export class SyncEngine {
71
75
  return typeof content === "string";
72
76
  }
73
77
 
78
+ /**
79
+ * Get a versioned URL from a handle (includes current heads).
80
+ * This ensures clients can fetch the exact version of the document.
81
+ */
82
+ private getVersionedUrl(handle: DocHandle<unknown>): AutomergeUrl {
83
+ const { documentId } = parseAutomergeUrl(handle.url);
84
+ const heads = handle.heads();
85
+ return stringifyAutomergeUrl({ documentId, heads });
86
+ }
87
+
74
88
  /**
75
89
  * Set the root directory URL in the snapshot
76
90
  */
@@ -86,7 +100,7 @@ export class SyncEngine {
86
100
  /**
87
101
  * Commit local changes only (no network sync)
88
102
  */
89
- async commitLocal(dryRun = false): Promise<SyncResult> {
103
+ async commitLocal(): Promise<SyncResult> {
90
104
  const result: SyncResult = {
91
105
  success: false,
92
106
  filesChanged: 0,
@@ -102,27 +116,20 @@ export class SyncEngine {
102
116
  snapshot = this.snapshotManager.createEmpty();
103
117
  }
104
118
 
105
- // Backup snapshot before starting
106
- if (!dryRun) {
107
- await this.snapshotManager.backup();
108
- }
109
-
110
119
  // Detect all changes
111
120
  const changes = await this.changeDetector.detectChanges(snapshot);
112
121
 
113
122
  // Detect moves
114
123
  const { moves, remainingChanges } = await this.moveDetector.detectMoves(
115
124
  changes,
116
- snapshot,
117
- this.rootPath
125
+ snapshot
118
126
  );
119
127
 
120
128
  // Apply local changes only (no network sync)
121
129
  const commitResult = await this.pushLocalChanges(
122
130
  remainingChanges,
123
131
  moves,
124
- snapshot,
125
- dryRun
132
+ snapshot
126
133
  );
127
134
 
128
135
  result.filesChanged += commitResult.filesChanged;
@@ -130,17 +137,18 @@ export class SyncEngine {
130
137
  result.errors.push(...commitResult.errors);
131
138
  result.warnings.push(...commitResult.warnings);
132
139
 
140
+ // Update directory URLs with current heads after all children are populated
141
+ await this.updateDirectoryUrlsLeafFirst(snapshot);
142
+
133
143
  // Touch root directory if any changes were made
134
144
  const hasChanges =
135
145
  result.filesChanged > 0 || result.directoriesChanged > 0;
136
146
  if (hasChanges) {
137
- await this.touchRootDirectory(snapshot, dryRun);
147
+ await this.touchRootDirectory(snapshot);
138
148
  }
139
149
 
140
- // Save updated snapshot if not dry run
141
- if (!dryRun) {
142
- await this.snapshotManager.save(snapshot);
143
- }
150
+ // Save updated snapshot
151
+ await this.snapshotManager.save(snapshot);
144
152
 
145
153
  result.success = result.errors.length === 0;
146
154
 
@@ -160,10 +168,7 @@ export class SyncEngine {
160
168
  /**
161
169
  * Run full bidirectional sync
162
170
  */
163
- async sync(dryRun = false): Promise<SyncResult> {
164
- const syncStartTime = Date.now();
165
- const timings: { [key: string]: number } = {};
166
-
171
+ async sync(): Promise<SyncResult> {
167
172
  const result: SyncResult = {
168
173
  success: false,
169
174
  filesChanged: 0,
@@ -173,189 +178,161 @@ export class SyncEngine {
173
178
  timings: {},
174
179
  };
175
180
 
176
- // Reset handles to wait on
177
- this.handlesToWaitOn = [];
181
+ // Reset tracked handles for sync
182
+ this.handlesByPath = new Map();
178
183
 
179
184
  try {
180
185
  // Load current snapshot
181
- const t0 = Date.now();
182
- let snapshot = await this.snapshotManager.load();
183
- timings["load_snapshot"] = Date.now() - t0;
184
- if (!snapshot) {
185
- snapshot = this.snapshotManager.createEmpty();
186
- }
186
+ const snapshot =
187
+ (await this.snapshotManager.load()) ||
188
+ this.snapshotManager.createEmpty();
187
189
 
188
- // Backup snapshot before starting
189
- const t1 = Date.now();
190
- if (!dryRun) {
191
- await this.snapshotManager.backup();
190
+ // Wait for initial sync to receive any pending remote changes
191
+ if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
192
+ try {
193
+ await waitForBidirectionalSync(
194
+ this.repo,
195
+ snapshot.rootDirectoryUrl,
196
+ this.config.sync_server_storage_id,
197
+ {
198
+ timeoutMs: 3000, // Short timeout for initial sync
199
+ pollIntervalMs: 100,
200
+ stableChecksRequired: 3,
201
+ }
202
+ );
203
+ } catch (error) {
204
+ out.taskLine(`Initial sync: ${error}`, true);
205
+ }
192
206
  }
193
- timings["backup_snapshot"] = Date.now() - t1;
194
207
 
195
208
  // Detect all changes
196
- const t2 = Date.now();
197
209
  const changes = await this.changeDetector.detectChanges(snapshot);
198
- timings["detect_changes"] = Date.now() - t2;
199
210
 
200
211
  // Detect moves
201
- const t3 = Date.now();
202
212
  const { moves, remainingChanges } = await this.moveDetector.detectMoves(
203
213
  changes,
204
- snapshot,
205
- this.rootPath
214
+ snapshot
206
215
  );
207
- timings["detect_moves"] = Date.now() - t3;
208
216
 
209
217
  // Phase 1: Push local changes to remote
210
- const t4 = Date.now();
211
218
  const phase1Result = await this.pushLocalChanges(
212
219
  remainingChanges,
213
220
  moves,
214
- snapshot,
215
- dryRun
221
+ snapshot
216
222
  );
217
- timings["phase1_push"] = Date.now() - t4;
218
223
 
219
224
  result.filesChanged += phase1Result.filesChanged;
220
225
  result.directoriesChanged += phase1Result.directoriesChanged;
221
226
  result.errors.push(...phase1Result.errors);
222
227
  result.warnings.push(...phase1Result.warnings);
223
228
 
224
- // Always wait for network sync when enabled (not just when local changes exist)
225
- // This is critical for clone scenarios where we need to pull remote changes
226
- const t5 = Date.now();
227
- timings["documents_to_sync"] = this.handlesToWaitOn.length;
228
- if (!dryRun && this.networkSyncEnabled) {
229
+ // Update directory URLs with current heads after all children are populated
230
+ await this.updateDirectoryUrlsLeafFirst(snapshot);
231
+
232
+ // Wait for network sync (important for clone scenarios)
233
+ if (this.config.sync_enabled) {
229
234
  try {
230
- // If we have a root directory URL, wait for it to sync
235
+ // If we have a root directory URL, add it to tracked handles
231
236
  if (snapshot.rootDirectoryUrl) {
237
+ const rootDirUrl = snapshot.rootDirectoryUrl;
232
238
  const rootHandle = await this.repo.find<DirectoryDocument>(
233
- snapshot.rootDirectoryUrl
239
+ rootDirUrl
234
240
  );
235
- this.handlesToWaitOn.push(rootHandle);
241
+ this.handlesByPath.set("", rootHandle);
236
242
  }
237
243
 
238
- if (this.handlesToWaitOn.length > 0) {
239
- const tWaitStart = Date.now();
244
+ if (this.handlesByPath.size > 0) {
245
+ // Sort handles leaf-first (deepest paths first, then shallower)
246
+ const sortedHandles = this.sortHandlesLeafFirst();
240
247
  await waitForSync(
241
- this.handlesToWaitOn,
242
- getSyncServerStorageId(this.syncServerStorageId)
248
+ sortedHandles,
249
+ this.config.sync_server_storage_id
243
250
  );
244
- timings["network_sync"] = Date.now() - tWaitStart;
245
-
246
- // CRITICAL: Wait a bit after our changes reach the server to allow
247
- // time for WebSocket to deliver OTHER peers' changes to us.
248
- // waitForSync only ensures OUR changes reached the server, not that
249
- // we've RECEIVED changes from other peers. This delay allows the
250
- // WebSocket protocol to propagate peer changes before we re-detect.
251
- // Without this, concurrent operations on different peers can miss
252
- // each other due to timing races.
253
- //
254
- // Optimization: Only wait if we pushed changes (shorter delay if no changes)
255
- const tDelayStart = Date.now();
256
- const delayMs = phase1Result.filesChanged > 0 ? 200 : 100;
257
- await new Promise((resolve) => setTimeout(resolve, delayMs));
258
- timings["post_sync_delay"] = Date.now() - tDelayStart;
259
251
  }
252
+
253
+ // Wait for bidirectional sync to stabilize.
254
+ // This polls document heads until they stop changing, which indicates
255
+ // that both our outgoing changes and any incoming peer changes have
256
+ // been received.
257
+ await waitForBidirectionalSync(
258
+ this.repo,
259
+ snapshot.rootDirectoryUrl,
260
+ this.config.sync_server_storage_id,
261
+ {
262
+ timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS,
263
+ pollIntervalMs: 100,
264
+ stableChecksRequired: 3,
265
+ }
266
+ );
260
267
  } catch (error) {
261
- console.error(`❌ Network sync failed: ${error}`);
268
+ out.taskLine(`Network sync failed: ${error}`, true);
262
269
  result.warnings.push(`Network sync failed: ${error}`);
263
270
  }
264
271
  }
265
- timings["total_network"] = Date.now() - t5;
266
272
 
267
- // Re-detect remote changes after network sync to ensure fresh state
268
- // This fixes race conditions where we detect changes before server propagation
269
- // NOTE: We DON'T update snapshot heads yet - that would prevent detecting remote changes!
270
- const t6 = Date.now();
273
+ // Re-detect changes after network sync for fresh state
271
274
  const freshChanges = await this.changeDetector.detectChanges(snapshot);
272
275
  const freshRemoteChanges = freshChanges.filter(
273
276
  (c) =>
274
277
  c.changeType === ChangeType.REMOTE_ONLY ||
275
278
  c.changeType === ChangeType.BOTH_CHANGED
276
279
  );
277
- timings["redetect_changes"] = Date.now() - t6;
278
280
 
279
281
  // Phase 2: Pull remote changes to local using fresh detection
280
- const t7 = Date.now();
281
282
  const phase2Result = await this.pullRemoteChanges(
282
283
  freshRemoteChanges,
283
- snapshot,
284
- dryRun
284
+ snapshot
285
285
  );
286
- timings["phase2_pull"] = Date.now() - t7;
287
286
  result.filesChanged += phase2Result.filesChanged;
288
287
  result.directoriesChanged += phase2Result.directoriesChanged;
289
288
  result.errors.push(...phase2Result.errors);
290
289
  result.warnings.push(...phase2Result.warnings);
291
290
 
292
- // CRITICAL FIX: Update snapshot heads AFTER pulling remote changes
293
- // This ensures that change detection can find remote changes, and we only
294
- // update the snapshot after the filesystem is in sync with the documents
295
- const t8 = Date.now();
296
- if (!dryRun) {
297
- // Update file document heads
298
- for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
299
- try {
300
- const handle = await this.repo.find(snapshotEntry.url);
301
- const currentHeads = handle.heads();
302
- if (!A.equals(currentHeads, snapshotEntry.head)) {
303
- // Update snapshot with current heads after pulling changes
304
- snapshot.files.set(filePath, {
305
- ...snapshotEntry,
306
- head: currentHeads,
307
- });
308
- }
309
- } catch (error) {
310
- // Handle might not exist if file was deleted, skip
311
- console.warn(`Could not update heads for ${filePath}: ${error}`);
291
+ // Update snapshot heads after pulling remote changes
292
+ for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
293
+ try {
294
+ const handle = await this.repo.find(snapshotEntry.url);
295
+ const currentHeads = handle.heads();
296
+ if (!A.equals(currentHeads, snapshotEntry.head)) {
297
+ // Update snapshot with current heads after pulling changes
298
+ snapshot.files.set(filePath, {
299
+ ...snapshotEntry,
300
+ head: currentHeads,
301
+ });
312
302
  }
303
+ } catch (error) {
304
+ // Handle might not exist if file was deleted
313
305
  }
306
+ }
314
307
 
315
- // Update directory document heads
316
- for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
317
- try {
318
- const handle = await this.repo.find(snapshotEntry.url);
319
- const currentHeads = handle.heads();
320
- if (!A.equals(currentHeads, snapshotEntry.head)) {
321
- // Update snapshot with current heads after pulling changes
322
- snapshot.directories.set(dirPath, {
323
- ...snapshotEntry,
324
- head: currentHeads,
325
- });
326
- }
327
- } catch (error) {
328
- // Handle might not exist if directory was deleted, skip
329
- console.warn(
330
- `Could not update heads for directory ${dirPath}: ${error}`
331
- );
308
+ // Update directory document heads
309
+ for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
310
+ try {
311
+ const handle = await this.repo.find(snapshotEntry.url);
312
+ const currentHeads = handle.heads();
313
+ if (!A.equals(currentHeads, snapshotEntry.head)) {
314
+ // Update snapshot with current heads after pulling changes
315
+ snapshot.directories.set(dirPath, {
316
+ ...snapshotEntry,
317
+ head: currentHeads,
318
+ });
332
319
  }
320
+ } catch (error) {
321
+ // Handle might not exist if directory was deleted
333
322
  }
334
323
  }
335
- timings["update_snapshot_heads"] = Date.now() - t8;
336
324
 
337
325
  // Touch root directory if any changes were made during sync
338
- const t9 = Date.now();
339
326
  const hasChanges =
340
327
  result.filesChanged > 0 || result.directoriesChanged > 0;
341
328
  if (hasChanges) {
342
- await this.touchRootDirectory(snapshot, dryRun);
329
+ await this.touchRootDirectory(snapshot);
343
330
  }
344
- timings["touch_root"] = Date.now() - t9;
345
331
 
346
332
  // Save updated snapshot if not dry run
347
- const t10 = Date.now();
348
- if (!dryRun) {
349
- await this.snapshotManager.save(snapshot);
350
- }
351
- timings["save_snapshot"] = Date.now() - t10;
352
-
353
- // Calculate total time
354
- const totalTime = Date.now() - syncStartTime;
355
- timings["total"] = totalTime;
333
+ await this.snapshotManager.save(snapshot);
356
334
 
357
335
  result.success = result.errors.length === 0;
358
- result.timings = timings;
359
336
  return result;
360
337
  } catch (error) {
361
338
  result.errors.push({
@@ -374,8 +351,7 @@ export class SyncEngine {
374
351
  private async pushLocalChanges(
375
352
  changes: DetectedChange[],
376
353
  moves: MoveCandidate[],
377
- snapshot: SyncSnapshot,
378
- dryRun: boolean
354
+ snapshot: SyncSnapshot
379
355
  ): Promise<SyncResult> {
380
356
  const result: SyncResult = {
381
357
  success: true,
@@ -388,7 +364,7 @@ export class SyncEngine {
388
364
  // Process moves first - all detected moves are applied
389
365
  for (const move of moves) {
390
366
  try {
391
- await this.applyMoveToRemote(move, snapshot, dryRun);
367
+ await this.applyMoveToRemote(move, snapshot);
392
368
  result.filesChanged++;
393
369
  } catch (error) {
394
370
  result.errors.push({
@@ -409,7 +385,7 @@ export class SyncEngine {
409
385
 
410
386
  for (const change of localChanges) {
411
387
  try {
412
- await this.applyLocalChangeToRemote(change, snapshot, dryRun);
388
+ await this.applyLocalChangeToRemote(change, snapshot);
413
389
  result.filesChanged++;
414
390
  } catch (error) {
415
391
  result.errors.push({
@@ -429,8 +405,7 @@ export class SyncEngine {
429
405
  */
430
406
  private async pullRemoteChanges(
431
407
  changes: DetectedChange[],
432
- snapshot: SyncSnapshot,
433
- dryRun: boolean
408
+ snapshot: SyncSnapshot
434
409
  ): Promise<SyncResult> {
435
410
  const result: SyncResult = {
436
411
  success: true,
@@ -452,7 +427,7 @@ export class SyncEngine {
452
427
 
453
428
  for (const change of sortedChanges) {
454
429
  try {
455
- await this.applyRemoteChangeToLocal(change, snapshot, dryRun);
430
+ await this.applyRemoteChangeToLocal(change, snapshot);
456
431
  result.filesChanged++;
457
432
  } catch (error) {
458
433
  result.errors.push({
@@ -472,47 +447,33 @@ export class SyncEngine {
472
447
  */
473
448
  private async applyLocalChangeToRemote(
474
449
  change: DetectedChange,
475
- snapshot: SyncSnapshot,
476
- dryRun: boolean
450
+ snapshot: SyncSnapshot
477
451
  ): Promise<void> {
478
452
  const snapshotEntry = snapshot.files.get(change.path);
479
453
 
480
- // CRITICAL: Check for null explicitly, not falsy values
481
- // Empty strings "" and empty Uint8Array are valid file content!
454
+ // Check for null (empty string/Uint8Array are valid content)
482
455
  if (change.localContent === null) {
483
456
  // File was deleted locally
484
457
  if (snapshotEntry) {
485
- await this.deleteRemoteFile(
486
- snapshotEntry.url,
487
- dryRun,
488
- snapshot,
489
- change.path
490
- );
458
+ await this.deleteRemoteFile(snapshotEntry.url, snapshot, change.path);
491
459
  // Remove from directory document
492
- await this.removeFileFromDirectory(snapshot, change.path, dryRun);
493
- if (!dryRun) {
494
- this.snapshotManager.removeFileEntry(snapshot, change.path);
495
- }
460
+ await this.removeFileFromDirectory(snapshot, change.path);
461
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
496
462
  }
497
463
  return;
498
464
  }
499
465
 
500
466
  if (!snapshotEntry) {
501
467
  // New file
502
- const handle = await this.createRemoteFile(change, dryRun);
503
- if (!dryRun && handle) {
504
- await this.addFileToDirectory(
505
- snapshot,
506
- change.path,
507
- handle.url,
508
- dryRun
509
- );
468
+ const handle = await this.createRemoteFile(change);
469
+ if (handle) {
470
+ // Use versioned URL (includes heads) so clients fetch correct version
471
+ const versionedUrl = this.getVersionedUrl(handle);
472
+ await this.addFileToDirectory(snapshot, change.path, versionedUrl);
510
473
 
511
- // CRITICAL FIX: Update snapshot with heads AFTER adding to directory
512
- // The addFileToDirectory call above may have changed the document heads
513
474
  this.snapshotManager.updateFileEntry(snapshot, change.path, {
514
- path: normalizePath(this.rootPath + "/" + change.path),
515
- url: handle.url,
475
+ path: joinAndNormalizePath(this.rootPath, change.path),
476
+ url: versionedUrl,
516
477
  head: handle.heads(),
517
478
  extension: getFileExtension(change.path),
518
479
  mimeType: getEnhancedMimeType(change.path),
@@ -523,7 +484,6 @@ export class SyncEngine {
523
484
  await this.updateRemoteFile(
524
485
  snapshotEntry.url,
525
486
  change.localContent,
526
- dryRun,
527
487
  snapshot,
528
488
  change.path
529
489
  );
@@ -535,10 +495,9 @@ export class SyncEngine {
535
495
  */
536
496
  private async applyRemoteChangeToLocal(
537
497
  change: DetectedChange,
538
- snapshot: SyncSnapshot,
539
- dryRun: boolean
498
+ snapshot: SyncSnapshot
540
499
  ): Promise<void> {
541
- const localPath = normalizePath(this.rootPath + "/" + change.path);
500
+ const localPath = joinAndNormalizePath(this.rootPath, change.path);
542
501
 
543
502
  if (!change.remoteHead) {
544
503
  throw new Error(
@@ -546,50 +505,51 @@ export class SyncEngine {
546
505
  );
547
506
  }
548
507
 
549
- // CRITICAL: Check for null explicitly, not falsy values
550
- // Empty strings "" and empty Uint8Array are valid file content!
508
+ // Check for null (empty string/Uint8Array are valid content)
551
509
  if (change.remoteContent === null) {
552
510
  // File was deleted remotely
553
- if (!dryRun) {
554
- await removePath(localPath);
555
- this.snapshotManager.removeFileEntry(snapshot, change.path);
556
- }
511
+ await removePath(localPath);
512
+ this.snapshotManager.removeFileEntry(snapshot, change.path);
557
513
  return;
558
514
  }
559
515
 
560
516
  // Create or update local file
561
- if (!dryRun) {
562
- await writeFileContent(localPath, change.remoteContent);
517
+ await writeFileContent(localPath, change.remoteContent);
563
518
 
564
- // Update or create snapshot entry for this file
565
- const snapshotEntry = snapshot.files.get(change.path);
566
- if (snapshotEntry) {
567
- // Update existing entry
568
- snapshotEntry.head = change.remoteHead;
569
- } else {
570
- // Create new snapshot entry for newly discovered remote file
571
- // We need to find the remote file's URL from the directory hierarchy
572
- if (snapshot.rootDirectoryUrl) {
573
- try {
574
- const fileEntry = await this.findFileInDirectoryHierarchy(
575
- snapshot.rootDirectoryUrl,
576
- change.path
577
- );
519
+ // Update or create snapshot entry for this file
520
+ const snapshotEntry = snapshot.files.get(change.path);
521
+ if (snapshotEntry) {
522
+ // Update existing entry
523
+ snapshotEntry.head = change.remoteHead;
524
+ } else {
525
+ // Create new snapshot entry for newly discovered remote file
526
+ // We need to find the remote file's URL from the directory hierarchy
527
+ if (snapshot.rootDirectoryUrl) {
528
+ try {
529
+ const fileEntry = await findFileInDirectoryHierarchy(
530
+ this.repo,
531
+ snapshot.rootDirectoryUrl,
532
+ change.path
533
+ );
578
534
 
579
- if (fileEntry) {
580
- this.snapshotManager.updateFileEntry(snapshot, change.path, {
581
- path: localPath,
582
- url: fileEntry.url,
583
- head: change.remoteHead,
584
- extension: getFileExtension(change.path),
585
- mimeType: getEnhancedMimeType(change.path),
586
- });
587
- }
588
- } catch (error) {
589
- console.warn(
590
- `Failed to update snapshot for remote file ${change.path}: ${error}`
591
- );
535
+ if (fileEntry) {
536
+ // Get versioned URL from handle (includes heads)
537
+ const fileHandle = await this.repo.find<FileDocument>(fileEntry.url);
538
+ const versionedUrl = this.getVersionedUrl(fileHandle);
539
+ this.snapshotManager.updateFileEntry(snapshot, change.path, {
540
+ path: localPath,
541
+ url: versionedUrl,
542
+ head: change.remoteHead,
543
+ extension: getFileExtension(change.path),
544
+ mimeType: getEnhancedMimeType(change.path),
545
+ });
592
546
  }
547
+ } catch (error) {
548
+ // Failed to update snapshot - file may have been deleted
549
+ out.taskLine(
550
+ `Warning: Failed to update snapshot for remote file ${change.path}`,
551
+ true
552
+ );
593
553
  }
594
554
  }
595
555
  }
@@ -600,90 +560,84 @@ export class SyncEngine {
600
560
  */
601
561
  private async applyMoveToRemote(
602
562
  move: MoveCandidate,
603
- snapshot: SyncSnapshot,
604
- dryRun: boolean
563
+ snapshot: SyncSnapshot
605
564
  ): Promise<void> {
606
565
  const fromEntry = snapshot.files.get(move.fromPath);
607
566
  if (!fromEntry) return;
608
567
 
609
568
  // Parse paths
610
- const fromParts = move.fromPath.split("/");
611
- const fromFileName = fromParts.pop() || "";
612
- const fromDirPath = fromParts.join("/");
613
-
614
569
  const toParts = move.toPath.split("/");
615
570
  const toFileName = toParts.pop() || "";
616
571
  const toDirPath = toParts.join("/");
617
572
 
618
- if (!dryRun) {
619
- // 1) Remove file entry from old directory document
620
- if (move.fromPath !== move.toPath) {
621
- await this.removeFileFromDirectory(snapshot, move.fromPath, dryRun);
622
- }
573
+ // 1) Remove file entry from old directory document
574
+ if (move.fromPath !== move.toPath) {
575
+ await this.removeFileFromDirectory(snapshot, move.fromPath);
576
+ }
623
577
 
624
- // 2) Ensure destination directory document exists and add file entry there
625
- const destDirUrl = await this.ensureDirectoryDocument(
626
- snapshot,
627
- toDirPath,
628
- dryRun
629
- );
630
- await this.addFileToDirectory(
631
- snapshot,
632
- move.toPath,
633
- fromEntry.url,
634
- dryRun
635
- );
578
+ // 2) Ensure destination directory document exists
579
+ await this.ensureDirectoryDocument(snapshot, toDirPath);
636
580
 
637
- // 3) Update the FileDocument name and content to match new location/state
638
- try {
639
- const handle = await this.repo.find<FileDocument>(fromEntry.url);
640
- const heads = fromEntry.head;
641
-
642
- // Update both name and content (if content changed during move)
643
- if (heads && heads.length > 0) {
644
- handle.changeAt(heads, (doc: FileDocument) => {
645
- doc.name = toFileName;
646
-
647
- // If new content is provided, update it (handles move + modification case)
648
- if (move.newContent !== undefined) {
649
- const isText = this.isTextContent(move.newContent);
650
- if (isText && typeof move.newContent === "string") {
651
- updateText(doc, ["content"], move.newContent);
652
- } else {
653
- doc.content = move.newContent;
654
- }
581
+ // 3) Update the FileDocument name and content to match new location/state
582
+ try {
583
+ // Use plain URL for mutable handle
584
+ const handle = await this.repo.find<FileDocument>(
585
+ getPlainUrl(fromEntry.url)
586
+ );
587
+ const heads = fromEntry.head;
588
+
589
+ // Update both name and content (if content changed during move)
590
+ if (heads && heads.length > 0) {
591
+ handle.changeAt(heads, (doc: FileDocument) => {
592
+ doc.name = toFileName;
593
+
594
+ // If new content is provided, update it (handles move + modification case)
595
+ if (move.newContent !== undefined) {
596
+ if (typeof move.newContent === "string") {
597
+ doc.content = new A.ImmutableString(move.newContent);
598
+ } else {
599
+ doc.content = move.newContent;
655
600
  }
656
- });
657
- } else {
658
- handle.change((doc: FileDocument) => {
659
- doc.name = toFileName;
660
-
661
- // If new content is provided, update it (handles move + modification case)
662
- if (move.newContent !== undefined) {
663
- const isText = this.isTextContent(move.newContent);
664
- if (isText && typeof move.newContent === "string") {
665
- updateText(doc, ["content"], move.newContent);
666
- } else {
667
- doc.content = move.newContent;
668
- }
601
+ }
602
+ });
603
+ } else {
604
+ handle.change((doc: FileDocument) => {
605
+ doc.name = toFileName;
606
+
607
+ // If new content is provided, update it (handles move + modification case)
608
+ if (move.newContent !== undefined) {
609
+ if (typeof move.newContent === "string") {
610
+ doc.content = new A.ImmutableString(move.newContent);
611
+ } else {
612
+ doc.content = move.newContent;
669
613
  }
670
- });
671
- }
672
- // Track file handle for network sync
673
- this.handlesToWaitOn.push(handle);
674
- } catch (e) {
675
- console.warn(
676
- `Failed to update file name for move ${move.fromPath} -> ${move.toPath}: ${e}`
677
- );
614
+ }
615
+ });
678
616
  }
679
617
 
680
- // 4) Update snapshot entries
618
+ // Get versioned URL after changes (includes current heads)
619
+ const versionedUrl = this.getVersionedUrl(handle);
620
+
621
+ // 4) Add file entry to destination directory with versioned URL
622
+ await this.addFileToDirectory(snapshot, move.toPath, versionedUrl);
623
+
624
+ // Track file handle for network sync
625
+ this.handlesByPath.set(move.toPath, handle);
626
+
627
+ // 5) Update snapshot entries
681
628
  this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
682
629
  this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
683
630
  ...fromEntry,
684
- path: normalizePath(this.rootPath + "/" + move.toPath),
685
- head: fromEntry.head, // will be updated later when heads advance
631
+ path: joinAndNormalizePath(this.rootPath, move.toPath),
632
+ url: versionedUrl,
633
+ head: handle.heads(),
686
634
  });
635
+ } catch (e) {
636
+ // Failed to update file name - file may have been deleted
637
+ out.taskLine(
638
+ `Warning: Failed to rename ${move.fromPath} to ${move.toPath}`,
639
+ true
640
+ );
687
641
  }
688
642
  }
689
643
 
@@ -691,12 +645,9 @@ export class SyncEngine {
691
645
  * Create new remote file document
692
646
  */
693
647
  private async createRemoteFile(
694
- change: DetectedChange,
695
- dryRun: boolean
648
+ change: DetectedChange
696
649
  ): Promise<DocHandle<FileDocument> | null> {
697
- // CRITICAL: Check for null explicitly, not falsy values
698
- // Empty strings "" and empty Uint8Array are valid file content!
699
- if (dryRun || change.localContent === null) return null;
650
+ if (change.localContent === null) return null;
700
651
 
701
652
  const isText = this.isTextContent(change.localContent);
702
653
 
@@ -706,7 +657,11 @@ export class SyncEngine {
706
657
  name: change.path.split("/").pop() || "",
707
658
  extension: getFileExtension(change.path),
708
659
  mimeType: getEnhancedMimeType(change.path),
709
- content: isText ? "" : change.localContent, // Empty string for text, actual content for binary
660
+ content: isText
661
+ ? new A.ImmutableString("")
662
+ : typeof change.localContent === "string"
663
+ ? new A.ImmutableString(change.localContent)
664
+ : change.localContent, // Empty ImmutableString for text, wrap strings for safety, actual content for binary
710
665
  metadata: {
711
666
  permissions: 0o644,
712
667
  },
@@ -714,16 +669,16 @@ export class SyncEngine {
714
669
 
715
670
  const handle = this.repo.create(fileDoc);
716
671
 
717
- // For text files, use updateText to set the content properly
672
+ // For text files, use ImmutableString for better performance
718
673
  if (isText && typeof change.localContent === "string") {
719
674
  handle.change((doc: FileDocument) => {
720
- updateText(doc, ["content"], change.localContent as string);
675
+ doc.content = new A.ImmutableString(change.localContent as string);
721
676
  });
722
677
  }
723
678
 
724
679
  // Always track newly created files for network sync
725
680
  // (they always represent a change that needs to sync)
726
- this.handlesToWaitOn.push(handle);
681
+ this.handlesByPath.set(change.path, handle);
727
682
 
728
683
  return handle;
729
684
  }
@@ -734,21 +689,18 @@ export class SyncEngine {
734
689
  private async updateRemoteFile(
735
690
  url: AutomergeUrl,
736
691
  content: string | Uint8Array,
737
- dryRun: boolean,
738
692
  snapshot: SyncSnapshot,
739
693
  filePath: string
740
694
  ): Promise<void> {
741
- if (dryRun) return;
742
-
743
- const handle = await this.repo.find<FileDocument>(url);
695
+ // Use plain URL for mutable handle
696
+ const handle = await this.repo.find<FileDocument>(getPlainUrl(url));
744
697
 
745
698
  // Check if content actually changed before tracking for sync
746
699
  const doc = await handle.doc();
747
700
  const currentContent = doc?.content;
748
701
  const contentChanged = !isContentEqual(content, currentContent);
749
702
 
750
- // CRITICAL FIX: Always update snapshot heads, even when content is identical
751
- // This prevents stale head issues that cause false change detection
703
+ // Update snapshot heads even when content is identical
752
704
  const snapshotEntry = snapshot.files.get(filePath);
753
705
  if (snapshotEntry) {
754
706
  // Update snapshot with current document heads
@@ -761,9 +713,6 @@ export class SyncEngine {
761
713
  if (!contentChanged) {
762
714
  // Content is identical, but we've updated the snapshot heads above
763
715
  // This prevents fresh change detection from seeing stale heads
764
- console.log(
765
- `🔍 Content is identical, but we've updated the snapshot heads above`
766
- );
767
716
  return;
768
717
  }
769
718
 
@@ -774,16 +723,15 @@ export class SyncEngine {
774
723
  }
775
724
 
776
725
  handle.changeAt(heads, (doc: FileDocument) => {
777
- const isText = this.isTextContent(content);
778
- if (isText && typeof content === "string") {
779
- updateText(doc, ["content"], content);
726
+ if (typeof content === "string") {
727
+ doc.content = new A.ImmutableString(content);
780
728
  } else {
781
729
  doc.content = content;
782
730
  }
783
731
  });
784
732
 
785
733
  // Update snapshot with new heads after content change
786
- if (!dryRun && snapshotEntry) {
734
+ if (snapshotEntry) {
787
735
  snapshot.files.set(filePath, {
788
736
  ...snapshotEntry,
789
737
  head: handle.heads(),
@@ -791,7 +739,7 @@ export class SyncEngine {
791
739
  }
792
740
 
793
741
  // Only track files that actually changed content
794
- this.handlesToWaitOn.push(handle);
742
+ this.handlesByPath.set(filePath, handle);
795
743
  }
796
744
 
797
745
  /**
@@ -799,16 +747,14 @@ export class SyncEngine {
799
747
  */
800
748
  private async deleteRemoteFile(
801
749
  url: AutomergeUrl,
802
- dryRun: boolean,
803
750
  snapshot?: SyncSnapshot,
804
751
  filePath?: string
805
752
  ): Promise<void> {
806
- if (dryRun) return;
807
-
808
753
  // In Automerge, we don't actually delete documents
809
754
  // They become orphaned and will be garbage collected
810
755
  // For now, we just mark them as deleted by clearing content
811
- const handle = await this.repo.find<FileDocument>(url);
756
+ // Use plain URL for mutable handle
757
+ const handle = await this.repo.find<FileDocument>(getPlainUrl(url));
812
758
  // const doc = await handle.doc(); // no longer needed
813
759
  let heads;
814
760
  if (snapshot && filePath) {
@@ -816,11 +762,11 @@ export class SyncEngine {
816
762
  }
817
763
  if (heads) {
818
764
  handle.changeAt(heads, (doc: FileDocument) => {
819
- doc.content = "";
765
+ doc.content = new A.ImmutableString("");
820
766
  });
821
767
  } else {
822
768
  handle.change((doc: FileDocument) => {
823
- doc.content = "";
769
+ doc.content = new A.ImmutableString("");
824
770
  });
825
771
  }
826
772
  }
@@ -831,10 +777,9 @@ export class SyncEngine {
831
777
  private async addFileToDirectory(
832
778
  snapshot: SyncSnapshot,
833
779
  filePath: string,
834
- fileUrl: AutomergeUrl,
835
- dryRun: boolean
780
+ fileUrl: AutomergeUrl
836
781
  ): Promise<void> {
837
- if (dryRun || !snapshot.rootDirectoryUrl) return;
782
+ if (!snapshot.rootDirectoryUrl) return;
838
783
 
839
784
  const pathParts = filePath.split("/");
840
785
  const fileName = pathParts.pop() || "";
@@ -843,11 +788,13 @@ export class SyncEngine {
843
788
  // Get or create the parent directory document
844
789
  const parentDirUrl = await this.ensureDirectoryDocument(
845
790
  snapshot,
846
- directoryPath,
847
- dryRun
791
+ directoryPath
848
792
  );
849
793
 
850
- const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
794
+ // Use plain URL for mutable handle
795
+ const dirHandle = await this.repo.find<DirectoryDocument>(
796
+ getPlainUrl(parentDirUrl)
797
+ );
851
798
 
852
799
  let didChange = false;
853
800
  const snapshotEntry = snapshot.directories.get(directoryPath);
@@ -881,14 +828,11 @@ export class SyncEngine {
881
828
  }
882
829
  });
883
830
  }
884
- if (didChange) {
885
- this.handlesToWaitOn.push(dirHandle);
886
-
887
- // CRITICAL FIX: Update snapshot with new directory heads immediately
888
- // This prevents stale head issues that cause convergence problems
889
- if (snapshotEntry) {
890
- snapshotEntry.head = dirHandle.heads();
891
- }
831
+ // Always track the directory (even if unchanged) for proper leaf-first sync ordering
832
+ this.handlesByPath.set(directoryPath, dirHandle);
833
+
834
+ if (didChange && snapshotEntry) {
835
+ snapshotEntry.head = dirHandle.heads();
892
836
  }
893
837
  }
894
838
 
@@ -898,8 +842,7 @@ export class SyncEngine {
898
842
  */
899
843
  private async ensureDirectoryDocument(
900
844
  snapshot: SyncSnapshot,
901
- directoryPath: string,
902
- dryRun: boolean
845
+ directoryPath: string
903
846
  ): Promise<AutomergeUrl> {
904
847
  // Root directory case
905
848
  if (!directoryPath || directoryPath === "") {
@@ -920,8 +863,7 @@ export class SyncEngine {
920
863
  // Ensure parent directory exists first (recursive)
921
864
  const parentDirUrl = await this.ensureDirectoryDocument(
922
865
  snapshot,
923
- parentPath,
924
- dryRun
866
+ parentPath
925
867
  );
926
868
 
927
869
  // DISCOVERY: Check if directory already exists in parent on server
@@ -944,35 +886,30 @@ export class SyncEngine {
944
886
  const childDirHandle = await this.repo.find<DirectoryDocument>(
945
887
  existingDirEntry.url
946
888
  );
947
- const childHeads = childDirHandle.heads();
948
-
949
- // Update snapshot with discovered directory using validated heads
950
- if (!dryRun) {
951
- this.snapshotManager.updateDirectoryEntry(
952
- snapshot,
953
- directoryPath,
954
- {
955
- path: normalizePath(this.rootPath + "/" + directoryPath),
956
- url: existingDirEntry.url,
957
- head: childHeads,
958
- entries: [],
959
- }
960
- );
961
- }
962
889
 
963
- return existingDirEntry.url;
890
+ // Track discovered directory for sync
891
+ this.handlesByPath.set(directoryPath, childDirHandle);
892
+
893
+ // Get versioned URL for storage (includes current heads)
894
+ const versionedUrl = this.getVersionedUrl(childDirHandle);
895
+
896
+ // Update snapshot with discovered directory using versioned URL
897
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
898
+ path: joinAndNormalizePath(this.rootPath, directoryPath),
899
+ url: versionedUrl,
900
+ head: childDirHandle.heads(),
901
+ entries: [],
902
+ });
903
+
904
+ // Return versioned URL (callers use getPlainUrl() when they need to modify)
905
+ return versionedUrl;
964
906
  } catch (resolveErr) {
965
- console.warn(
966
- `Failed to resolve child directory ${currentDirName} at ${directoryPath}: ${resolveErr}`
967
- );
968
- // Fall through to create a fresh directory document
907
+ // Failed to resolve directory - fall through to create a fresh directory document
969
908
  }
970
909
  }
971
910
  }
972
911
  } catch (error) {
973
- console.warn(
974
- `Failed to check for existing directory ${currentDirName}: ${error}`
975
- );
912
+ // Failed to check for existing directory - will create new one
976
913
  }
977
914
 
978
915
  // CREATE: Directory doesn't exist, create new one
@@ -983,8 +920,14 @@ export class SyncEngine {
983
920
 
984
921
  const dirHandle = this.repo.create(dirDoc);
985
922
 
923
+ // Get versioned URL for the new directory (includes heads)
924
+ const versionedDirUrl = this.getVersionedUrl(dirHandle);
925
+
986
926
  // Add this directory to its parent
987
- const parentHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
927
+ // Use plain URL for mutable handle
928
+ const parentHandle = await this.repo.find<DirectoryDocument>(
929
+ getPlainUrl(parentDirUrl)
930
+ );
988
931
 
989
932
  let didChange = false;
990
933
  parentHandle.change((doc: DirectoryDocument) => {
@@ -997,36 +940,33 @@ export class SyncEngine {
997
940
  doc.docs.push({
998
941
  name: currentDirName,
999
942
  type: "folder",
1000
- url: dirHandle.url,
943
+ url: versionedDirUrl,
1001
944
  });
1002
945
  didChange = true;
1003
946
  }
1004
947
  });
1005
948
 
1006
949
  // Track directory handles for sync
1007
- if (!dryRun) {
1008
- this.handlesToWaitOn.push(dirHandle);
1009
- if (didChange) {
1010
- this.handlesToWaitOn.push(parentHandle);
1011
-
1012
- // CRITICAL FIX: Update parent directory heads in snapshot immediately
1013
- // This prevents stale head issues when parent directory is modified
1014
- const parentSnapshotEntry = snapshot.directories.get(parentPath);
1015
- if (parentSnapshotEntry) {
1016
- parentSnapshotEntry.head = parentHandle.heads();
1017
- }
1018
- }
950
+ this.handlesByPath.set(directoryPath, dirHandle);
951
+ if (didChange) {
952
+ this.handlesByPath.set(parentPath, parentHandle);
1019
953
 
1020
- // Update snapshot with new directory
1021
- this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1022
- path: normalizePath(this.rootPath + "/" + directoryPath),
1023
- url: dirHandle.url,
1024
- head: dirHandle.heads(),
1025
- entries: [],
1026
- });
954
+ const parentSnapshotEntry = snapshot.directories.get(parentPath);
955
+ if (parentSnapshotEntry) {
956
+ parentSnapshotEntry.head = parentHandle.heads();
957
+ }
1027
958
  }
1028
959
 
1029
- return dirHandle.url;
960
+ // Update snapshot with new directory (use versioned URL for storage)
961
+ this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
962
+ path: joinAndNormalizePath(this.rootPath, directoryPath),
963
+ url: versionedDirUrl,
964
+ head: dirHandle.heads(),
965
+ entries: [],
966
+ });
967
+
968
+ // Return versioned URL (callers use getPlainUrl() when they need to modify)
969
+ return versionedDirUrl;
1030
970
  }
1031
971
 
1032
972
  /**
@@ -1034,10 +974,9 @@ export class SyncEngine {
1034
974
  */
1035
975
  private async removeFileFromDirectory(
1036
976
  snapshot: SyncSnapshot,
1037
- filePath: string,
1038
- dryRun: boolean
977
+ filePath: string
1039
978
  ): Promise<void> {
1040
- if (dryRun || !snapshot.rootDirectoryUrl) return;
979
+ if (!snapshot.rootDirectoryUrl) return;
1041
980
 
1042
981
  const pathParts = filePath.split("/");
1043
982
  const fileName = pathParts.pop() || "";
@@ -1050,19 +989,20 @@ export class SyncEngine {
1050
989
  } else {
1051
990
  const existingDir = snapshot.directories.get(directoryPath);
1052
991
  if (!existingDir) {
1053
- console.warn(
1054
- `Directory ${directoryPath} not found in snapshot for file removal`
1055
- );
992
+ // Directory not found - file may already be removed
1056
993
  return;
1057
994
  }
1058
995
  parentDirUrl = existingDir.url;
1059
996
  }
1060
997
 
1061
998
  try {
1062
- const dirHandle = await this.repo.find<DirectoryDocument>(parentDirUrl);
999
+ // Use plain URL for mutable handle
1000
+ const dirHandle = await this.repo.find<DirectoryDocument>(
1001
+ getPlainUrl(parentDirUrl)
1002
+ );
1063
1003
 
1064
1004
  // Track this handle for network sync waiting
1065
- this.handlesToWaitOn.push(dirHandle);
1005
+ this.handlesByPath.set(directoryPath, dirHandle);
1066
1006
  const snapshotEntry = snapshot.directories.get(directoryPath);
1067
1007
  const heads = snapshotEntry?.head;
1068
1008
  let didChange = false;
@@ -1075,9 +1015,9 @@ export class SyncEngine {
1075
1015
  if (indexToRemove !== -1) {
1076
1016
  doc.docs.splice(indexToRemove, 1);
1077
1017
  didChange = true;
1078
- console.log(
1079
- `🗑️ Removed ${fileName} from directory ${
1080
- directoryPath || "root"
1018
+ out.taskLine(
1019
+ `Removed ${fileName} from ${
1020
+ formatRelativePath(directoryPath) || "root"
1081
1021
  }`
1082
1022
  );
1083
1023
  }
@@ -1090,83 +1030,23 @@ export class SyncEngine {
1090
1030
  if (indexToRemove !== -1) {
1091
1031
  doc.docs.splice(indexToRemove, 1);
1092
1032
  didChange = true;
1093
- console.log(
1094
- `🗑️ Removed ${fileName} from directory ${
1095
- directoryPath || "root"
1033
+ out.taskLine(
1034
+ `Removed ${fileName} from ${
1035
+ formatRelativePath(directoryPath) || "root"
1096
1036
  }`
1097
1037
  );
1098
1038
  }
1099
1039
  });
1100
1040
  }
1101
1041
 
1102
- // CRITICAL FIX: Update snapshot with new directory heads immediately
1103
- // This prevents stale head issues that cause convergence problems
1104
1042
  if (didChange && snapshotEntry) {
1105
1043
  snapshotEntry.head = dirHandle.heads();
1106
1044
  }
1107
1045
  } catch (error) {
1108
- console.warn(
1109
- `Failed to remove ${fileName} from directory ${
1110
- directoryPath || "root"
1111
- }: ${error}`
1112
- );
1113
1046
  throw error;
1114
1047
  }
1115
1048
  }
1116
1049
 
1117
- /**
1118
- * Find a file in the directory hierarchy by path
1119
- */
1120
- private async findFileInDirectoryHierarchy(
1121
- directoryUrl: AutomergeUrl,
1122
- filePath: string
1123
- ): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
1124
- try {
1125
- const pathParts = filePath.split("/");
1126
- let currentDirUrl = directoryUrl;
1127
-
1128
- // Navigate through directories to find the parent directory
1129
- for (let i = 0; i < pathParts.length - 1; i++) {
1130
- const dirName = pathParts[i];
1131
- const dirHandle = await this.repo.find<DirectoryDocument>(
1132
- currentDirUrl
1133
- );
1134
- const dirDoc = await dirHandle.doc();
1135
-
1136
- if (!dirDoc) return null;
1137
-
1138
- const subDirEntry = dirDoc.docs.find(
1139
- (entry: { name: string; type: string; url: AutomergeUrl }) =>
1140
- entry.name === dirName && entry.type === "folder"
1141
- );
1142
-
1143
- if (!subDirEntry) return null;
1144
- currentDirUrl = subDirEntry.url;
1145
- }
1146
-
1147
- // Now look for the file in the final directory
1148
- const fileName = pathParts[pathParts.length - 1];
1149
- const finalDirHandle = await this.repo.find<DirectoryDocument>(
1150
- currentDirUrl
1151
- );
1152
- const finalDirDoc = await finalDirHandle.doc();
1153
-
1154
- if (!finalDirDoc) return null;
1155
-
1156
- const fileEntry = finalDirDoc.docs.find(
1157
- (entry: { name: string; type: string; url: AutomergeUrl }) =>
1158
- entry.name === fileName && entry.type === "file"
1159
- );
1160
-
1161
- return fileEntry || null;
1162
- } catch (error) {
1163
- console.warn(
1164
- `Failed to find file ${filePath} in directory hierarchy: ${error}`
1165
- );
1166
- return null;
1167
- }
1168
- }
1169
-
1170
1050
  /**
1171
1051
  * Sort changes by dependency order
1172
1052
  */
@@ -1227,11 +1107,7 @@ export class SyncEngine {
1227
1107
  }
1228
1108
 
1229
1109
  const changes = await this.changeDetector.detectChanges(snapshot);
1230
- const { moves } = await this.moveDetector.detectMoves(
1231
- changes,
1232
- snapshot,
1233
- this.rootPath
1234
- );
1110
+ const { moves } = await this.moveDetector.detectMoves(changes, snapshot);
1235
1111
 
1236
1112
  const summary = this.generateChangeSummary(changes, moves);
1237
1113
 
@@ -1293,11 +1169,8 @@ export class SyncEngine {
1293
1169
  /**
1294
1170
  * Update the lastSyncAt timestamp on the root directory document
1295
1171
  */
1296
- private async touchRootDirectory(
1297
- snapshot: SyncSnapshot,
1298
- dryRun: boolean
1299
- ): Promise<void> {
1300
- if (dryRun || !snapshot.rootDirectoryUrl) {
1172
+ private async touchRootDirectory(snapshot: SyncSnapshot): Promise<void> {
1173
+ if (!snapshot.rootDirectoryUrl) {
1301
1174
  return;
1302
1175
  }
1303
1176
 
@@ -1322,21 +1195,300 @@ export class SyncEngine {
1322
1195
  }
1323
1196
 
1324
1197
  // Track root directory for network sync
1325
- this.handlesToWaitOn.push(rootHandle);
1198
+ this.handlesByPath.set("", rootHandle);
1326
1199
 
1327
- // CRITICAL FIX: Update root directory heads in snapshot immediately
1328
- // This prevents stale head issues when root directory is modified
1329
1200
  if (snapshotEntry) {
1330
1201
  snapshotEntry.head = rootHandle.heads();
1331
1202
  }
1332
-
1333
- console.log(
1334
- `🕒 Updated root directory lastSyncAt to ${new Date(
1335
- timestamp
1336
- ).toISOString()}`
1337
- );
1338
1203
  } catch (error) {
1339
- console.warn(`Failed to update root directory lastSyncAt: ${error}`);
1204
+ // Failed to update root directory timestamp
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Sort tracked handles leaf-first (deepest paths first).
1210
+ * Returns handles in sorted order, logging URLs with heads for debugging.
1211
+ */
1212
+ private sortHandlesLeafFirst(): DocHandle<unknown>[] {
1213
+ // Sort paths by depth (descending - deepest first), then alphabetically
1214
+ const sortedPaths = Array.from(this.handlesByPath.keys()).sort((a, b) => {
1215
+ const depthA = a ? a.split("/").length : 0;
1216
+ const depthB = b ? b.split("/").length : 0;
1217
+
1218
+ // Deepest first
1219
+ if (depthA !== depthB) {
1220
+ return depthB - depthA;
1221
+ }
1222
+
1223
+ // Alphabetically by path
1224
+ return a.localeCompare(b);
1225
+ });
1226
+
1227
+ // Log the sync order with versioned URLs for debugging (keep on complete)
1228
+ const handles: DocHandle<unknown>[] = [];
1229
+ for (const path of sortedPaths) {
1230
+ const handle = this.handlesByPath.get(path)!;
1231
+ const versionedUrl = this.getVersionedUrl(handle);
1232
+ out.taskLine(`Sync: ${path || "(root)"} -> ${versionedUrl}`, true);
1233
+ handles.push(handle);
1234
+ }
1235
+
1236
+ return handles;
1237
+ }
1238
+
1239
+ /**
1240
+ * Update all URLs (files and directories) in directory documents with current heads.
1241
+ *
1242
+ * This MUST be called AFTER all changes are applied but BEFORE network sync.
1243
+ * The problem it solves:
1244
+ * 1. When we create/update a file or directory and store its URL, the URL captures
1245
+ * the heads at that moment
1246
+ * 2. Later operations may advance the document's heads
1247
+ * 3. But the URL stored in the parent directory has stale heads
1248
+ * 4. Clients reading the directory would get old views of entries
1249
+ *
1250
+ * The fix: walk leaf-first and update all entry URLs with current heads,
1251
+ * AFTER all changes have been applied. This ensures clients get consistent,
1252
+ * up-to-date versioned URLs.
1253
+ */
1254
+ private async updateDirectoryUrlsLeafFirst(
1255
+ snapshot: SyncSnapshot
1256
+ ): Promise<void> {
1257
+ // First, update file URLs in their parent directories
1258
+ await this.updateFileUrlsInDirectories(snapshot);
1259
+
1260
+ // Then, update directory URLs in their parent directories (leaf-first)
1261
+ await this.updateSubdirectoryUrls(snapshot);
1262
+ }
1263
+
1264
+ /**
1265
+ * Update file URLs in directory documents with current heads.
1266
+ */
1267
+ private async updateFileUrlsInDirectories(
1268
+ snapshot: SyncSnapshot
1269
+ ): Promise<void> {
1270
+ // Group files by their parent directory
1271
+ const filesByDir = new Map<string, string[]>();
1272
+
1273
+ for (const filePath of snapshot.files.keys()) {
1274
+ const pathParts = filePath.split("/");
1275
+ pathParts.pop(); // Remove filename
1276
+ const dirPath = pathParts.join("/");
1277
+
1278
+ if (!filesByDir.has(dirPath)) {
1279
+ filesByDir.set(dirPath, []);
1280
+ }
1281
+ filesByDir.get(dirPath)!.push(filePath);
1282
+ }
1283
+
1284
+ // Process each directory that has files
1285
+ for (const [dirPath, filePaths] of filesByDir.entries()) {
1286
+ try {
1287
+ // Get the directory URL
1288
+ let dirUrl: AutomergeUrl;
1289
+ if (!dirPath || dirPath === "") {
1290
+ if (!snapshot.rootDirectoryUrl) continue;
1291
+ dirUrl = snapshot.rootDirectoryUrl;
1292
+ } else {
1293
+ const dirEntry = snapshot.directories.get(dirPath);
1294
+ if (!dirEntry) continue;
1295
+ dirUrl = dirEntry.url;
1296
+ }
1297
+
1298
+ // Get directory handle
1299
+ const dirHandle = await this.repo.find<DirectoryDocument>(
1300
+ getPlainUrl(dirUrl)
1301
+ );
1302
+
1303
+ // Get current heads for changeAt
1304
+ const snapshotEntry = snapshot.directories.get(dirPath);
1305
+ const heads = snapshotEntry?.head;
1306
+
1307
+ // Build a map of file names to their current versioned URLs
1308
+ const fileUrlUpdates = new Map<string, AutomergeUrl>();
1309
+
1310
+ for (const filePath of filePaths) {
1311
+ const fileEntry = snapshot.files.get(filePath);
1312
+ if (!fileEntry) continue;
1313
+
1314
+ // Get current handle for this file
1315
+ const fileHandle = await this.repo.find<FileDocument>(
1316
+ getPlainUrl(fileEntry.url)
1317
+ );
1318
+
1319
+ // Get versioned URL with current heads
1320
+ const currentVersionedUrl = this.getVersionedUrl(fileHandle);
1321
+
1322
+ // Update snapshot entry
1323
+ snapshot.files.set(filePath, {
1324
+ ...fileEntry,
1325
+ url: currentVersionedUrl,
1326
+ head: fileHandle.heads(),
1327
+ });
1328
+
1329
+ // Store for directory update
1330
+ const fileName = filePath.split("/").pop() || "";
1331
+ fileUrlUpdates.set(fileName, currentVersionedUrl);
1332
+ }
1333
+
1334
+ // Update all file entries in the directory document
1335
+ let didChange = false;
1336
+ if (heads) {
1337
+ dirHandle.changeAt(heads, (doc: DirectoryDocument) => {
1338
+ for (const [fileName, newUrl] of fileUrlUpdates) {
1339
+ const existingIndex = doc.docs.findIndex(
1340
+ (entry) => entry.name === fileName && entry.type === "file"
1341
+ );
1342
+ if (existingIndex !== -1 && doc.docs[existingIndex].url !== newUrl) {
1343
+ doc.docs[existingIndex].url = newUrl;
1344
+ didChange = true;
1345
+ }
1346
+ }
1347
+ });
1348
+ } else {
1349
+ dirHandle.change((doc: DirectoryDocument) => {
1350
+ for (const [fileName, newUrl] of fileUrlUpdates) {
1351
+ const existingIndex = doc.docs.findIndex(
1352
+ (entry) => entry.name === fileName && entry.type === "file"
1353
+ );
1354
+ if (existingIndex !== -1 && doc.docs[existingIndex].url !== newUrl) {
1355
+ doc.docs[existingIndex].url = newUrl;
1356
+ didChange = true;
1357
+ }
1358
+ }
1359
+ });
1360
+ }
1361
+
1362
+ // Track directory and update heads
1363
+ if (didChange) {
1364
+ this.handlesByPath.set(dirPath, dirHandle);
1365
+ if (snapshotEntry) {
1366
+ snapshotEntry.head = dirHandle.heads();
1367
+ }
1368
+ }
1369
+ } catch (error) {
1370
+ out.taskLine(
1371
+ `Warning: Failed to update file URLs in directory ${dirPath}`,
1372
+ true
1373
+ );
1374
+ }
1375
+ }
1376
+ }
1377
+
1378
+ /**
1379
+ * Update subdirectory URLs in parent directories with current heads.
1380
+ * Processes leaf-first (deepest directories first).
1381
+ */
1382
+ private async updateSubdirectoryUrls(snapshot: SyncSnapshot): Promise<void> {
1383
+ // Get all directory paths and sort leaf-first (deepest first)
1384
+ const directoryPaths = Array.from(snapshot.directories.keys()).sort(
1385
+ (a, b) => {
1386
+ const depthA = a ? a.split("/").length : 0;
1387
+ const depthB = b ? b.split("/").length : 0;
1388
+
1389
+ // Deepest first
1390
+ if (depthA !== depthB) {
1391
+ return depthB - depthA;
1392
+ }
1393
+
1394
+ // Alphabetically by path
1395
+ return a.localeCompare(b);
1396
+ }
1397
+ );
1398
+
1399
+ // Update each directory's URL in its parent
1400
+ for (const dirPath of directoryPaths) {
1401
+ // Skip root directory (has no parent)
1402
+ if (!dirPath || dirPath === "") {
1403
+ continue;
1404
+ }
1405
+
1406
+ const dirEntry = snapshot.directories.get(dirPath);
1407
+ if (!dirEntry) continue;
1408
+
1409
+ try {
1410
+ // Get current handle for this directory (use plain URL to get mutable handle)
1411
+ const dirHandle = await this.repo.find<DirectoryDocument>(
1412
+ getPlainUrl(dirEntry.url)
1413
+ );
1414
+
1415
+ // Get versioned URL with CURRENT heads (after all children populated)
1416
+ const currentVersionedUrl = this.getVersionedUrl(dirHandle);
1417
+
1418
+ // Update snapshot entry with current heads and versioned URL
1419
+ snapshot.directories.set(dirPath, {
1420
+ ...dirEntry,
1421
+ url: currentVersionedUrl,
1422
+ head: dirHandle.heads(),
1423
+ });
1424
+
1425
+ // Get parent path
1426
+ const pathParts = dirPath.split("/");
1427
+ const dirName = pathParts.pop() || "";
1428
+ const parentPath = pathParts.join("/");
1429
+
1430
+ // Get parent directory handle
1431
+ let parentDirUrl: AutomergeUrl;
1432
+ if (!parentPath || parentPath === "") {
1433
+ // Parent is root
1434
+ if (!snapshot.rootDirectoryUrl) continue;
1435
+ parentDirUrl = snapshot.rootDirectoryUrl;
1436
+ } else {
1437
+ const parentEntry = snapshot.directories.get(parentPath);
1438
+ if (!parentEntry) continue;
1439
+ parentDirUrl = parentEntry.url;
1440
+ }
1441
+
1442
+ // Update the directory entry in the parent with the new versioned URL
1443
+ const parentHandle = await this.repo.find<DirectoryDocument>(
1444
+ getPlainUrl(parentDirUrl)
1445
+ );
1446
+
1447
+ // Get parent's current heads for changeAt
1448
+ const parentSnapshotEntry =
1449
+ parentPath === ""
1450
+ ? snapshot.directories.get("")
1451
+ : snapshot.directories.get(parentPath);
1452
+ const parentHeads = parentSnapshotEntry?.head;
1453
+
1454
+ let didChange = false;
1455
+ if (parentHeads) {
1456
+ parentHandle.changeAt(parentHeads, (doc: DirectoryDocument) => {
1457
+ const existingIndex = doc.docs.findIndex(
1458
+ (entry) => entry.name === dirName && entry.type === "folder"
1459
+ );
1460
+ if (existingIndex !== -1) {
1461
+ // Update the URL with current versioned URL
1462
+ doc.docs[existingIndex].url = currentVersionedUrl;
1463
+ didChange = true;
1464
+ }
1465
+ });
1466
+ } else {
1467
+ parentHandle.change((doc: DirectoryDocument) => {
1468
+ const existingIndex = doc.docs.findIndex(
1469
+ (entry) => entry.name === dirName && entry.type === "folder"
1470
+ );
1471
+ if (existingIndex !== -1) {
1472
+ // Update the URL with current versioned URL
1473
+ doc.docs[existingIndex].url = currentVersionedUrl;
1474
+ didChange = true;
1475
+ }
1476
+ });
1477
+ }
1478
+
1479
+ // Track parent for sync and update its heads in snapshot
1480
+ if (didChange) {
1481
+ this.handlesByPath.set(parentPath, parentHandle);
1482
+ if (parentSnapshotEntry) {
1483
+ parentSnapshotEntry.head = parentHandle.heads();
1484
+ }
1485
+ }
1486
+ } catch (error) {
1487
+ out.taskLine(
1488
+ `Warning: Failed to update directory URL for ${dirPath}`,
1489
+ true
1490
+ );
1491
+ }
1340
1492
  }
1341
1493
  }
1342
1494
  }