pushwork 2.0.0-a.sub.1 → 2.0.0-preview

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 (251) hide show
  1. package/dist/branches.d.ts +19 -0
  2. package/dist/branches.d.ts.map +1 -0
  3. package/dist/branches.js +111 -0
  4. package/dist/branches.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +238 -272
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +17 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +84 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/fs-tree.d.ts +6 -0
  14. package/dist/fs-tree.d.ts.map +1 -0
  15. package/dist/fs-tree.js +99 -0
  16. package/dist/fs-tree.js.map +1 -0
  17. package/dist/ignore.d.ts +6 -0
  18. package/dist/ignore.d.ts.map +1 -0
  19. package/dist/ignore.js +74 -0
  20. package/dist/ignore.js.map +1 -0
  21. package/dist/index.d.ts +8 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +34 -4
  24. package/dist/index.js.map +1 -1
  25. package/dist/log.d.ts +3 -0
  26. package/dist/log.d.ts.map +1 -0
  27. package/dist/log.js +14 -0
  28. package/dist/log.js.map +1 -0
  29. package/dist/pushwork.d.ts +115 -0
  30. package/dist/pushwork.d.ts.map +1 -0
  31. package/dist/pushwork.js +918 -0
  32. package/dist/pushwork.js.map +1 -0
  33. package/dist/repo.d.ts +14 -0
  34. package/dist/repo.d.ts.map +1 -0
  35. package/dist/repo.js +60 -0
  36. package/dist/repo.js.map +1 -0
  37. package/dist/shapes/custom.d.ts +3 -0
  38. package/dist/shapes/custom.d.ts.map +1 -0
  39. package/dist/shapes/custom.js +57 -0
  40. package/dist/shapes/custom.js.map +1 -0
  41. package/dist/shapes/file.d.ts +20 -0
  42. package/dist/shapes/file.d.ts.map +1 -0
  43. package/dist/shapes/file.js +140 -0
  44. package/dist/shapes/file.js.map +1 -0
  45. package/dist/shapes/index.d.ts +10 -0
  46. package/dist/shapes/index.d.ts.map +1 -0
  47. package/dist/shapes/index.js +35 -0
  48. package/dist/shapes/index.js.map +1 -0
  49. package/dist/shapes/patchwork-folder.d.ts +3 -0
  50. package/dist/shapes/patchwork-folder.d.ts.map +1 -0
  51. package/dist/shapes/patchwork-folder.js +160 -0
  52. package/dist/shapes/patchwork-folder.js.map +1 -0
  53. package/dist/shapes/types.d.ts +37 -0
  54. package/dist/shapes/types.d.ts.map +1 -0
  55. package/dist/shapes/types.js +52 -0
  56. package/dist/shapes/types.js.map +1 -0
  57. package/dist/shapes/vfs.d.ts +3 -0
  58. package/dist/shapes/vfs.d.ts.map +1 -0
  59. package/dist/shapes/vfs.js +88 -0
  60. package/dist/shapes/vfs.js.map +1 -0
  61. package/dist/stash.d.ts +23 -0
  62. package/dist/stash.d.ts.map +1 -0
  63. package/dist/stash.js +118 -0
  64. package/dist/stash.js.map +1 -0
  65. package/flake.lock +128 -0
  66. package/flake.nix +66 -0
  67. package/package.json +15 -48
  68. package/patches/@automerge__automerge-repo@2.6.0-subduction.15.patch +26 -0
  69. package/pnpm-workspace.yaml +5 -0
  70. package/src/branches.ts +93 -0
  71. package/src/cli.ts +258 -408
  72. package/src/config.ts +64 -0
  73. package/src/fs-tree.ts +70 -0
  74. package/src/ignore.ts +33 -0
  75. package/src/index.ts +38 -4
  76. package/src/log.ts +8 -0
  77. package/src/pushwork.ts +1055 -0
  78. package/src/repo.ts +76 -0
  79. package/src/shapes/custom.ts +29 -0
  80. package/src/shapes/file.ts +115 -0
  81. package/src/shapes/index.ts +19 -0
  82. package/src/shapes/patchwork-folder.ts +156 -0
  83. package/src/shapes/types.ts +79 -0
  84. package/src/shapes/vfs.ts +93 -0
  85. package/src/stash.ts +106 -0
  86. package/test/integration/branches.test.ts +389 -0
  87. package/test/integration/pushwork.test.ts +547 -0
  88. package/test/setup.ts +29 -0
  89. package/test/unit/doc-shape.test.ts +612 -0
  90. package/tsconfig.json +2 -3
  91. package/vitest.config.ts +14 -0
  92. package/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +0 -248
  93. package/CLAUDE.md +0 -141
  94. package/README.md +0 -221
  95. package/babel.config.js +0 -5
  96. package/dist/cli/commands.d.ts +0 -71
  97. package/dist/cli/commands.d.ts.map +0 -1
  98. package/dist/cli/commands.js +0 -794
  99. package/dist/cli/commands.js.map +0 -1
  100. package/dist/cli/index.d.ts +0 -2
  101. package/dist/cli/index.d.ts.map +0 -1
  102. package/dist/cli/index.js +0 -19
  103. package/dist/cli/index.js.map +0 -1
  104. package/dist/commands.d.ts +0 -61
  105. package/dist/commands.d.ts.map +0 -1
  106. package/dist/commands.js +0 -861
  107. package/dist/commands.js.map +0 -1
  108. package/dist/config/index.d.ts +0 -71
  109. package/dist/config/index.d.ts.map +0 -1
  110. package/dist/config/index.js +0 -314
  111. package/dist/config/index.js.map +0 -1
  112. package/dist/core/change-detection.d.ts +0 -80
  113. package/dist/core/change-detection.d.ts.map +0 -1
  114. package/dist/core/change-detection.js +0 -523
  115. package/dist/core/change-detection.js.map +0 -1
  116. package/dist/core/config.d.ts +0 -81
  117. package/dist/core/config.d.ts.map +0 -1
  118. package/dist/core/config.js +0 -258
  119. package/dist/core/config.js.map +0 -1
  120. package/dist/core/index.d.ts +0 -6
  121. package/dist/core/index.d.ts.map +0 -1
  122. package/dist/core/index.js +0 -6
  123. package/dist/core/index.js.map +0 -1
  124. package/dist/core/move-detection.d.ts +0 -34
  125. package/dist/core/move-detection.d.ts.map +0 -1
  126. package/dist/core/move-detection.js +0 -121
  127. package/dist/core/move-detection.js.map +0 -1
  128. package/dist/core/snapshot.d.ts +0 -105
  129. package/dist/core/snapshot.d.ts.map +0 -1
  130. package/dist/core/snapshot.js +0 -217
  131. package/dist/core/snapshot.js.map +0 -1
  132. package/dist/core/sync-engine.d.ts +0 -157
  133. package/dist/core/sync-engine.d.ts.map +0 -1
  134. package/dist/core/sync-engine.js +0 -1379
  135. package/dist/core/sync-engine.js.map +0 -1
  136. package/dist/types/config.d.ts +0 -99
  137. package/dist/types/config.d.ts.map +0 -1
  138. package/dist/types/config.js +0 -5
  139. package/dist/types/config.js.map +0 -1
  140. package/dist/types/documents.d.ts +0 -88
  141. package/dist/types/documents.d.ts.map +0 -1
  142. package/dist/types/documents.js +0 -20
  143. package/dist/types/documents.js.map +0 -1
  144. package/dist/types/index.d.ts +0 -4
  145. package/dist/types/index.d.ts.map +0 -1
  146. package/dist/types/index.js +0 -4
  147. package/dist/types/index.js.map +0 -1
  148. package/dist/types/snapshot.d.ts +0 -64
  149. package/dist/types/snapshot.d.ts.map +0 -1
  150. package/dist/types/snapshot.js +0 -2
  151. package/dist/types/snapshot.js.map +0 -1
  152. package/dist/utils/content-similarity.d.ts +0 -53
  153. package/dist/utils/content-similarity.d.ts.map +0 -1
  154. package/dist/utils/content-similarity.js +0 -155
  155. package/dist/utils/content-similarity.js.map +0 -1
  156. package/dist/utils/content.d.ts +0 -10
  157. package/dist/utils/content.d.ts.map +0 -1
  158. package/dist/utils/content.js +0 -31
  159. package/dist/utils/content.js.map +0 -1
  160. package/dist/utils/directory.d.ts +0 -24
  161. package/dist/utils/directory.d.ts.map +0 -1
  162. package/dist/utils/directory.js +0 -52
  163. package/dist/utils/directory.js.map +0 -1
  164. package/dist/utils/fs.d.ts +0 -74
  165. package/dist/utils/fs.d.ts.map +0 -1
  166. package/dist/utils/fs.js +0 -248
  167. package/dist/utils/fs.js.map +0 -1
  168. package/dist/utils/index.d.ts +0 -5
  169. package/dist/utils/index.d.ts.map +0 -1
  170. package/dist/utils/index.js +0 -5
  171. package/dist/utils/index.js.map +0 -1
  172. package/dist/utils/mime-types.d.ts +0 -13
  173. package/dist/utils/mime-types.d.ts.map +0 -1
  174. package/dist/utils/mime-types.js +0 -209
  175. package/dist/utils/mime-types.js.map +0 -1
  176. package/dist/utils/network-sync.d.ts +0 -36
  177. package/dist/utils/network-sync.d.ts.map +0 -1
  178. package/dist/utils/network-sync.js +0 -250
  179. package/dist/utils/network-sync.js.map +0 -1
  180. package/dist/utils/node-polyfills.d.ts +0 -9
  181. package/dist/utils/node-polyfills.d.ts.map +0 -1
  182. package/dist/utils/node-polyfills.js +0 -9
  183. package/dist/utils/node-polyfills.js.map +0 -1
  184. package/dist/utils/output.d.ts +0 -129
  185. package/dist/utils/output.d.ts.map +0 -1
  186. package/dist/utils/output.js +0 -368
  187. package/dist/utils/output.js.map +0 -1
  188. package/dist/utils/repo-factory.d.ts +0 -13
  189. package/dist/utils/repo-factory.d.ts.map +0 -1
  190. package/dist/utils/repo-factory.js +0 -46
  191. package/dist/utils/repo-factory.js.map +0 -1
  192. package/dist/utils/string-similarity.d.ts +0 -14
  193. package/dist/utils/string-similarity.d.ts.map +0 -1
  194. package/dist/utils/string-similarity.js +0 -39
  195. package/dist/utils/string-similarity.js.map +0 -1
  196. package/dist/utils/text-diff.d.ts +0 -37
  197. package/dist/utils/text-diff.d.ts.map +0 -1
  198. package/dist/utils/text-diff.js +0 -93
  199. package/dist/utils/text-diff.js.map +0 -1
  200. package/dist/utils/trace.d.ts +0 -19
  201. package/dist/utils/trace.d.ts.map +0 -1
  202. package/dist/utils/trace.js +0 -63
  203. package/dist/utils/trace.js.map +0 -1
  204. package/src/commands.ts +0 -1134
  205. package/src/core/change-detection.ts +0 -712
  206. package/src/core/config.ts +0 -313
  207. package/src/core/index.ts +0 -5
  208. package/src/core/move-detection.ts +0 -169
  209. package/src/core/snapshot.ts +0 -275
  210. package/src/core/sync-engine.ts +0 -1795
  211. package/src/types/config.ts +0 -111
  212. package/src/types/documents.ts +0 -91
  213. package/src/types/index.ts +0 -3
  214. package/src/types/snapshot.ts +0 -67
  215. package/src/utils/content.ts +0 -34
  216. package/src/utils/directory.ts +0 -73
  217. package/src/utils/fs.ts +0 -297
  218. package/src/utils/index.ts +0 -4
  219. package/src/utils/mime-types.ts +0 -244
  220. package/src/utils/network-sync.ts +0 -319
  221. package/src/utils/node-polyfills.ts +0 -8
  222. package/src/utils/output.ts +0 -450
  223. package/src/utils/repo-factory.ts +0 -73
  224. package/src/utils/string-similarity.ts +0 -54
  225. package/src/utils/text-diff.ts +0 -101
  226. package/src/utils/trace.ts +0 -70
  227. package/test/integration/README.md +0 -328
  228. package/test/integration/clone-test.sh +0 -310
  229. package/test/integration/conflict-resolution-test.sh +0 -309
  230. package/test/integration/debug-both-nested.sh +0 -74
  231. package/test/integration/debug-concurrent-nested.sh +0 -87
  232. package/test/integration/debug-nested.sh +0 -73
  233. package/test/integration/deletion-behavior-test.sh +0 -487
  234. package/test/integration/deletion-sync-test-simple.sh +0 -193
  235. package/test/integration/deletion-sync-test.sh +0 -297
  236. package/test/integration/exclude-patterns.test.ts +0 -144
  237. package/test/integration/full-integration-test.sh +0 -363
  238. package/test/integration/fuzzer.test.ts +0 -818
  239. package/test/integration/in-memory-sync.test.ts +0 -830
  240. package/test/integration/init-sync.test.ts +0 -89
  241. package/test/integration/manual-sync-test.sh +0 -84
  242. package/test/integration/sync-deletion.test.ts +0 -280
  243. package/test/integration/sync-flow.test.ts +0 -291
  244. package/test/jest.setup.ts +0 -34
  245. package/test/run-tests.sh +0 -225
  246. package/test/unit/deletion-behavior.test.ts +0 -249
  247. package/test/unit/enhanced-mime-detection.test.ts +0 -244
  248. package/test/unit/snapshot.test.ts +0 -404
  249. package/test/unit/sync-convergence.test.ts +0 -298
  250. package/test/unit/sync-timing.test.ts +0 -134
  251. package/test/unit/utils.test.ts +0 -366
@@ -1,1379 +0,0 @@
1
- import { parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo";
2
- import * as A from "@automerge/automerge";
3
- import { ChangeType, FileType, } from "../types/index.js";
4
- import { writeFileContent, removePath, getFileExtension, getEnhancedMimeType, formatRelativePath, findFileInDirectoryHierarchy, joinAndNormalizePath, getPlainUrl, updateTextContent, readDocContent, } from "../utils/index.js";
5
- import { isContentEqual, contentHash } from "../utils/content.js";
6
- import { waitForSync, waitForBidirectionalSync } from "../utils/network-sync.js";
7
- import { SnapshotManager } from "./snapshot.js";
8
- import { ChangeDetector } from "./change-detection.js";
9
- import { MoveDetector } from "./move-detection.js";
10
- import { out } from "../utils/output.js";
11
- import * as path from "path";
12
- const isDebug = !!process.env.DEBUG;
13
- function debug(...args) {
14
- if (isDebug)
15
- console.error("[pushwork:engine]", ...args);
16
- }
17
- /**
18
- * Apply a change to a document handle, using changeAt when heads are available
19
- * to branch from a known version, otherwise falling back to change.
20
- */
21
- function changeWithOptionalHeads(handle, heads, callback) {
22
- if (heads && heads.length > 0) {
23
- handle.changeAt(heads, callback);
24
- }
25
- else {
26
- handle.change(callback);
27
- }
28
- }
29
- /**
30
- * Sync configuration constants
31
- */
32
- const BIDIRECTIONAL_SYNC_TIMEOUT_MS = 5000; // Timeout for bidirectional sync stability check
33
- /**
34
- * Bidirectional sync engine implementing two-phase sync
35
- */
36
- export class SyncEngine {
37
- constructor(repo, rootPath, config) {
38
- this.repo = repo;
39
- this.rootPath = rootPath;
40
- // Map from path to handle for leaf-first sync ordering
41
- // Path depth determines sync order (deepest first)
42
- this.handlesByPath = new Map();
43
- this.config = config;
44
- this.snapshotManager = new SnapshotManager(rootPath);
45
- this.changeDetector = new ChangeDetector(repo, rootPath, config.exclude_patterns, config.artifact_directories || []);
46
- this.moveDetector = new MoveDetector(config.sync.move_detection_threshold);
47
- }
48
- /**
49
- * Determine if content should be treated as text for Automerge text operations
50
- * Note: This method checks the runtime type. File type detection happens
51
- * during reading with isEnhancedTextFile() which now has better dev file support.
52
- */
53
- isTextContent(content) {
54
- // Simply check the actual type of the content
55
- return typeof content === "string";
56
- }
57
- /**
58
- * Get a versioned URL from a handle (includes current heads).
59
- * This ensures clients can fetch the exact version of the document.
60
- */
61
- getVersionedUrl(handle) {
62
- const { documentId } = parseAutomergeUrl(handle.url);
63
- const heads = handle.heads();
64
- return stringifyAutomergeUrl({ documentId, heads });
65
- }
66
- /**
67
- * Determine if a file path is inside an artifact directory.
68
- * Artifact files are stored as immutable strings (RawString) and
69
- * referenced with versioned URLs in directory entries.
70
- */
71
- isArtifactPath(filePath) {
72
- const artifactDirs = this.config.artifact_directories || [];
73
- return artifactDirs.some(dir => filePath === dir || filePath.startsWith(dir + "/"));
74
- }
75
- /**
76
- * Get the appropriate URL for a file's directory entry.
77
- * Artifact paths get versioned URLs (with heads) for exact version fetching.
78
- * Non-artifact paths get plain URLs for collaborative editing.
79
- */
80
- getEntryUrl(handle, filePath) {
81
- if (this.isArtifactPath(filePath)) {
82
- return this.getVersionedUrl(handle);
83
- }
84
- return getPlainUrl(handle.url);
85
- }
86
- /**
87
- * Get the appropriate URL for a subdirectory's directory entry.
88
- * Always uses plain URLs — versioned URLs on directories can cause
89
- * issues where consumers see a version without the docs array.
90
- */
91
- getDirEntryUrl(handle) {
92
- return getPlainUrl(handle.url);
93
- }
94
- /**
95
- * Set the root directory URL in the snapshot
96
- */
97
- async setRootDirectoryUrl(url) {
98
- let snapshot = await this.snapshotManager.load();
99
- if (!snapshot) {
100
- snapshot = this.snapshotManager.createEmpty();
101
- }
102
- snapshot.rootDirectoryUrl = url;
103
- await this.snapshotManager.save(snapshot);
104
- }
105
- /**
106
- * Reset the snapshot, clearing all tracked files and directories.
107
- * Preserves the rootDirectoryUrl so sync can still operate.
108
- * Used by --force to re-sync every file.
109
- */
110
- async resetSnapshot() {
111
- let snapshot = await this.snapshotManager.load();
112
- if (!snapshot)
113
- return;
114
- this.snapshotManager.clear(snapshot);
115
- await this.snapshotManager.save(snapshot);
116
- }
117
- /**
118
- * Nuclear reset: clear the snapshot AND wipe the root directory document's
119
- * entries so that every file and subdirectory gets brand-new Automerge
120
- * documents. The root directory document itself is preserved.
121
- */
122
- async nuclearReset() {
123
- let snapshot = await this.snapshotManager.load();
124
- if (!snapshot)
125
- return;
126
- // Clear the root directory document's entries
127
- if (snapshot.rootDirectoryUrl) {
128
- const rootHandle = await this.repo.find(getPlainUrl(snapshot.rootDirectoryUrl));
129
- rootHandle.change((doc) => {
130
- doc.docs.splice(0, doc.docs.length);
131
- });
132
- }
133
- // Clear all tracked files and directories from snapshot
134
- this.snapshotManager.clear(snapshot);
135
- await this.snapshotManager.save(snapshot);
136
- }
137
- /**
138
- * Commit local changes only (no network sync)
139
- */
140
- async commitLocal() {
141
- const result = {
142
- success: false,
143
- filesChanged: 0,
144
- directoriesChanged: 0,
145
- errors: [],
146
- warnings: [],
147
- };
148
- try {
149
- // Load current snapshot
150
- let snapshot = await this.snapshotManager.load();
151
- if (!snapshot) {
152
- snapshot = this.snapshotManager.createEmpty();
153
- }
154
- // Detect all changes
155
- const changes = await this.changeDetector.detectChanges(snapshot);
156
- // Detect moves
157
- const { moves, remainingChanges } = await this.moveDetector.detectMoves(changes, snapshot);
158
- // Apply local changes only (no network sync)
159
- const commitResult = await this.pushLocalChanges(remainingChanges, moves, snapshot);
160
- result.filesChanged += commitResult.filesChanged;
161
- result.directoriesChanged += commitResult.directoriesChanged;
162
- result.errors.push(...commitResult.errors);
163
- result.warnings.push(...commitResult.warnings);
164
- // Always touch root directory after commit
165
- await this.touchRootDirectory(snapshot);
166
- // Save updated snapshot
167
- await this.snapshotManager.save(snapshot);
168
- result.success = result.errors.length === 0;
169
- return result;
170
- }
171
- catch (error) {
172
- result.errors.push({
173
- path: this.rootPath,
174
- operation: "commitLocal",
175
- error: error instanceof Error ? error : new Error(String(error)),
176
- recoverable: true,
177
- });
178
- result.success = false;
179
- return result;
180
- }
181
- }
182
- /**
183
- * Recreate documents that failed to sync. Creates new Automerge documents
184
- * with the same content and updates all references (snapshot, parent directory).
185
- * Returns new handles that should be retried for sync.
186
- */
187
- async recreateFailedDocuments(failedHandles, snapshot) {
188
- const failedUrls = new Set(failedHandles.map(h => getPlainUrl(h.url)));
189
- const newHandles = [];
190
- // Find which paths correspond to the failed handles
191
- for (const [filePath, entry] of snapshot.files.entries()) {
192
- const plainUrl = getPlainUrl(entry.url);
193
- if (!failedUrls.has(plainUrl))
194
- continue;
195
- debug(`recreate: recreating document for ${filePath} (${plainUrl})`);
196
- out.taskLine(`Recreating document for ${filePath}`);
197
- try {
198
- // Read the current content from the old handle
199
- const oldHandle = await this.repo.find(plainUrl);
200
- const doc = await oldHandle.doc();
201
- if (!doc) {
202
- debug(`recreate: could not read doc for ${filePath}, skipping`);
203
- continue;
204
- }
205
- const content = readDocContent(doc.content);
206
- if (content === null) {
207
- debug(`recreate: null content for ${filePath}, skipping`);
208
- continue;
209
- }
210
- // Create a fresh document
211
- const fakeChange = {
212
- path: filePath,
213
- changeType: ChangeType.LOCAL_ONLY,
214
- fileType: this.isTextContent(content) ? FileType.TEXT : FileType.BINARY,
215
- localContent: content,
216
- remoteContent: null,
217
- };
218
- const newHandle = await this.createRemoteFile(fakeChange);
219
- if (!newHandle)
220
- continue;
221
- const entryUrl = this.getEntryUrl(newHandle, filePath);
222
- // Update snapshot entry
223
- this.snapshotManager.updateFileEntry(snapshot, filePath, {
224
- ...entry,
225
- url: entryUrl,
226
- head: newHandle.heads(),
227
- ...(this.isArtifactPath(filePath) ? { contentHash: contentHash(content) } : {}),
228
- });
229
- // Update parent directory entry to point to new document
230
- const pathParts = filePath.split("/");
231
- const fileName = pathParts.pop() || "";
232
- const dirPath = pathParts.join("/");
233
- let dirUrl;
234
- if (!dirPath || dirPath === "") {
235
- dirUrl = snapshot.rootDirectoryUrl;
236
- }
237
- else {
238
- const dirEntry = snapshot.directories.get(dirPath);
239
- if (!dirEntry)
240
- continue;
241
- dirUrl = dirEntry.url;
242
- }
243
- const dirHandle = await this.repo.find(getPlainUrl(dirUrl));
244
- dirHandle.change((d) => {
245
- const idx = d.docs.findIndex(e => e.name === fileName && e.type === "file");
246
- if (idx !== -1) {
247
- d.docs[idx].url = entryUrl;
248
- }
249
- });
250
- // Track new handles
251
- this.handlesByPath.set(filePath, newHandle);
252
- this.handlesByPath.set(dirPath, dirHandle);
253
- newHandles.push(newHandle);
254
- newHandles.push(dirHandle);
255
- debug(`recreate: created new doc for ${filePath} -> ${newHandle.url}`);
256
- }
257
- catch (error) {
258
- debug(`recreate: failed for ${filePath}: ${error}`);
259
- out.taskLine(`Failed to recreate ${filePath}: ${error}`, true);
260
- }
261
- }
262
- // Also check directory documents
263
- for (const [dirPath, entry] of snapshot.directories.entries()) {
264
- const plainUrl = getPlainUrl(entry.url);
265
- if (!failedUrls.has(plainUrl))
266
- continue;
267
- // Directory docs can't be easily recreated (they reference children).
268
- // Just log a warning — the child recreation above should handle most cases.
269
- debug(`recreate: directory ${dirPath || "(root)"} failed to sync, cannot recreate`);
270
- out.taskLine(`Warning: directory ${dirPath || "(root)"} failed to sync`, true);
271
- }
272
- return newHandles;
273
- }
274
- /**
275
- * Run full bidirectional sync
276
- */
277
- async sync() {
278
- const result = {
279
- success: false,
280
- filesChanged: 0,
281
- directoriesChanged: 0,
282
- errors: [],
283
- warnings: [],
284
- timings: {},
285
- };
286
- // Reset tracked handles for sync
287
- this.handlesByPath = new Map();
288
- try {
289
- // Load current snapshot
290
- const snapshot = (await this.snapshotManager.load()) ||
291
- this.snapshotManager.createEmpty();
292
- debug(`sync: rootDirectoryUrl=${snapshot.rootDirectoryUrl}, files=${snapshot.files.size}, dirs=${snapshot.directories.size}`);
293
- // Wait for initial sync to receive any pending remote changes
294
- if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
295
- debug("sync: waiting for root document to be ready");
296
- out.update("Waiting for root document from server");
297
- // Wait for the root document to be fetched from the network.
298
- // repo.find() rejects with "unavailable" if the server doesn't
299
- // have the document yet, so we retry with backoff.
300
- // This is critical for clone scenarios.
301
- const plainRootUrl = getPlainUrl(snapshot.rootDirectoryUrl);
302
- const maxAttempts = 6;
303
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
304
- try {
305
- const rootHandle = await this.repo.find(plainRootUrl);
306
- rootHandle.doc(); // throws if not ready
307
- debug(`sync: root document ready (attempt ${attempt})`);
308
- break;
309
- }
310
- catch (error) {
311
- const isUnavailable = String(error).includes("unavailable") || String(error).includes("not ready");
312
- if (isUnavailable && attempt < maxAttempts) {
313
- const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
314
- debug(`sync: root document not available (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms`);
315
- out.update(`Waiting for root document (attempt ${attempt}/${maxAttempts})`);
316
- await new Promise(r => setTimeout(r, delay));
317
- }
318
- else {
319
- debug(`sync: root document unavailable after ${maxAttempts} attempts: ${error}`);
320
- out.taskLine(`Root document unavailable: ${error}`, true);
321
- break;
322
- }
323
- }
324
- }
325
- debug("sync: waiting for initial bidirectional sync");
326
- out.update("Waiting for initial sync from server");
327
- try {
328
- await waitForBidirectionalSync(this.repo, snapshot.rootDirectoryUrl, {
329
- timeoutMs: 5000, // Increased timeout for initial sync
330
- pollIntervalMs: 100,
331
- stableChecksRequired: 3,
332
- });
333
- }
334
- catch (error) {
335
- out.taskLine(`Initial sync: ${error}`, true);
336
- }
337
- }
338
- // Detect all changes
339
- debug("sync: detecting changes");
340
- out.update("Detecting local and remote changes");
341
- // Capture pre-push snapshot file paths to detect deletions after push
342
- const prePushFilePaths = new Set(snapshot.files.keys());
343
- const changes = await this.changeDetector.detectChanges(snapshot);
344
- // Detect moves
345
- const { moves, remainingChanges } = await this.moveDetector.detectMoves(changes, snapshot);
346
- debug(`sync: detected ${changes.length} changes, ${moves.length} moves, ${remainingChanges.length} remaining`);
347
- // Phase 1: Push local changes to remote
348
- debug("sync: phase 1 - pushing local changes");
349
- const phase1Result = await this.pushLocalChanges(remainingChanges, moves, snapshot);
350
- result.filesChanged += phase1Result.filesChanged;
351
- result.directoriesChanged += phase1Result.directoriesChanged;
352
- result.errors.push(...phase1Result.errors);
353
- result.warnings.push(...phase1Result.warnings);
354
- debug(`sync: phase 1 complete - ${phase1Result.filesChanged} files, ${phase1Result.directoriesChanged} dirs changed`);
355
- // Wait for network sync (important for clone scenarios)
356
- if (this.config.sync_enabled) {
357
- try {
358
- // Ensure root directory handle is tracked for sync
359
- if (snapshot.rootDirectoryUrl) {
360
- const rootHandle = await this.repo.find(snapshot.rootDirectoryUrl);
361
- this.handlesByPath.set("", rootHandle);
362
- }
363
- // Single waitForSync with ALL tracked handles at once
364
- if (this.handlesByPath.size > 0) {
365
- const allHandles = Array.from(this.handlesByPath.values());
366
- const handlePaths = Array.from(this.handlesByPath.keys());
367
- debug(`sync: waiting for ${allHandles.length} handles to sync to server: ${handlePaths.slice(0, 10).map(p => p || "(root)").join(", ")}${handlePaths.length > 10 ? ` ...and ${handlePaths.length - 10} more` : ""}`);
368
- out.update(`Uploading ${allHandles.length} documents to sync server`);
369
- const { failed } = await waitForSync(allHandles);
370
- // Recreate failed documents and retry once
371
- if (failed.length > 0) {
372
- debug(`sync: ${failed.length} documents failed, recreating`);
373
- out.update(`Recreating ${failed.length} failed documents`);
374
- const retryHandles = await this.recreateFailedDocuments(failed, snapshot);
375
- if (retryHandles.length > 0) {
376
- debug(`sync: retrying ${retryHandles.length} recreated handles`);
377
- out.update(`Retrying ${retryHandles.length} recreated documents`);
378
- const retry = await waitForSync(retryHandles);
379
- if (retry.failed.length > 0) {
380
- const msg = `${retry.failed.length} documents failed to sync to server after recreation`;
381
- debug(`sync: ${msg}`);
382
- result.errors.push({
383
- path: "sync",
384
- operation: "upload",
385
- error: new Error(msg),
386
- recoverable: true,
387
- });
388
- }
389
- }
390
- }
391
- debug("sync: all handles synced to server");
392
- }
393
- // Wait for bidirectional sync to stabilize
394
- // Use tracked handles for post-push check (cheaper than full tree scan)
395
- const changedHandles = Array.from(this.handlesByPath.values());
396
- debug(`sync: waiting for bidirectional sync to stabilize (${changedHandles.length} tracked handles)`);
397
- out.update("Waiting for bidirectional sync to stabilize");
398
- await waitForBidirectionalSync(this.repo, snapshot.rootDirectoryUrl, {
399
- timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS,
400
- pollIntervalMs: 100,
401
- stableChecksRequired: 3,
402
- handles: changedHandles.length > 0 ? changedHandles : undefined,
403
- });
404
- // Root directory touch + sync moved to end of sync() so it always runs
405
- }
406
- catch (error) {
407
- debug(`sync: network sync error: ${error}`);
408
- out.taskLine(`Network sync failed: ${error}`, true);
409
- result.errors.push({
410
- path: "sync",
411
- operation: "network-sync",
412
- error: error instanceof Error ? error : new Error(String(error)),
413
- recoverable: true,
414
- });
415
- }
416
- }
417
- // Re-detect changes after network sync for fresh state
418
- // Compute paths deleted during push so they aren't resurrected during pull
419
- const deletedPaths = new Set();
420
- for (const p of prePushFilePaths) {
421
- if (!snapshot.files.has(p)) {
422
- deletedPaths.add(p);
423
- }
424
- }
425
- if (deletedPaths.size > 0) {
426
- debug(`sync: excluding ${deletedPaths.size} deleted paths from re-detection`);
427
- }
428
- debug("sync: re-detecting changes after network sync");
429
- const freshChanges = await this.changeDetector.detectChanges(snapshot, deletedPaths);
430
- const freshRemoteChanges = freshChanges.filter(c => c.changeType === ChangeType.REMOTE_ONLY ||
431
- c.changeType === ChangeType.BOTH_CHANGED);
432
- debug(`sync: phase 2 - pulling ${freshRemoteChanges.length} remote changes`);
433
- if (freshRemoteChanges.length > 0) {
434
- out.update(`Pulling ${freshRemoteChanges.length} remote changes`);
435
- }
436
- // Phase 2: Pull remote changes to local using fresh detection
437
- const phase2Result = await this.pullRemoteChanges(freshRemoteChanges, snapshot);
438
- result.filesChanged += phase2Result.filesChanged;
439
- result.directoriesChanged += phase2Result.directoriesChanged;
440
- result.errors.push(...phase2Result.errors);
441
- result.warnings.push(...phase2Result.warnings);
442
- // Update snapshot heads after pulling remote changes
443
- // IMPORTANT: Use getPlainUrl() to strip version/heads from URLs.
444
- // Artifact entries store versioned URLs (with heads baked in).
445
- // repo.find(versionedUrl) returns a view handle whose .heads()
446
- // returns the VERSION heads, not the current document heads.
447
- // Using the versioned URL here would overwrite correct heads with
448
- // stale ones, causing changeAt() to fork from the wrong point
449
- // on the next sync (e.g. an empty directory state where deletions
450
- // can't find the entries to splice out).
451
- for (const [filePath, snapshotEntry] of snapshot.files.entries()) {
452
- try {
453
- const handle = await this.repo.find(getPlainUrl(snapshotEntry.url));
454
- const currentHeads = handle.heads();
455
- if (!A.equals(currentHeads, snapshotEntry.head)) {
456
- // Update snapshot with current heads after pulling changes
457
- snapshot.files.set(filePath, {
458
- ...snapshotEntry,
459
- head: currentHeads,
460
- });
461
- }
462
- }
463
- catch (error) {
464
- // Handle might not exist if file was deleted
465
- }
466
- }
467
- // Update directory document heads
468
- for (const [dirPath, snapshotEntry] of snapshot.directories.entries()) {
469
- try {
470
- const handle = await this.repo.find(getPlainUrl(snapshotEntry.url));
471
- const currentHeads = handle.heads();
472
- if (!A.equals(currentHeads, snapshotEntry.head)) {
473
- // Update snapshot with current heads after pulling changes
474
- snapshot.directories.set(dirPath, {
475
- ...snapshotEntry,
476
- head: currentHeads,
477
- });
478
- }
479
- }
480
- catch (error) {
481
- // Handle might not exist if directory was deleted
482
- }
483
- }
484
- // Small pause before touching root to let everything settle
485
- await new Promise(r => setTimeout(r, 100));
486
- // Always touch root directory after sync completes
487
- await this.touchRootDirectory(snapshot);
488
- if (this.config.sync_enabled && snapshot.rootDirectoryUrl) {
489
- const rootHandle = await this.repo.find(snapshot.rootDirectoryUrl);
490
- debug("sync: syncing root directory touch to server");
491
- out.update("Syncing root directory update");
492
- await waitForSync([rootHandle]);
493
- // Wait for the touch to fully stabilize on the server
494
- debug("sync: waiting for root touch to stabilize");
495
- await waitForBidirectionalSync(this.repo, snapshot.rootDirectoryUrl, {
496
- timeoutMs: 5000,
497
- pollIntervalMs: 100,
498
- stableChecksRequired: 3,
499
- handles: [rootHandle],
500
- });
501
- // Flush repo to ensure everything is persisted
502
- await this.repo.flush();
503
- // Small grace period to ensure server has flushed
504
- await new Promise(r => setTimeout(r, 100));
505
- }
506
- // Update root directory snapshot heads after touch
507
- const rootSnapshotEntry = snapshot.directories.get("");
508
- if (rootSnapshotEntry && snapshot.rootDirectoryUrl) {
509
- try {
510
- const rootHandle = await this.repo.find(getPlainUrl(snapshot.rootDirectoryUrl));
511
- rootSnapshotEntry.head = rootHandle.heads();
512
- }
513
- catch (error) {
514
- debug(`sync: failed to update root snapshot heads after touch: ${error}`);
515
- }
516
- }
517
- // Save updated snapshot
518
- await this.snapshotManager.save(snapshot);
519
- result.success = result.errors.length === 0;
520
- return result;
521
- }
522
- catch (error) {
523
- result.errors.push({
524
- path: "sync",
525
- operation: "full-sync",
526
- error: error,
527
- recoverable: false,
528
- });
529
- return result;
530
- }
531
- }
532
- /**
533
- * Phase 1: Push local changes to Automerge documents.
534
- *
535
- * Works depth-first: processes the deepest files first, creates/updates all
536
- * file docs at each level, then batch-updates the parent directory document
537
- * in a single change. Propagates subdirectory URL updates as we walk up
538
- * toward the root. This eliminates the need for a separate URL update pass.
539
- */
540
- async pushLocalChanges(changes, moves, snapshot) {
541
- const result = {
542
- success: true,
543
- filesChanged: 0,
544
- directoriesChanged: 0,
545
- errors: [],
546
- warnings: [],
547
- };
548
- // Process moves first - all detected moves are applied
549
- if (moves.length > 0) {
550
- debug(`push: processing ${moves.length} moves`);
551
- out.update(`Processing ${moves.length} move${moves.length > 1 ? "s" : ""}`);
552
- }
553
- for (let i = 0; i < moves.length; i++) {
554
- const move = moves[i];
555
- try {
556
- debug(`push: move ${i + 1}/${moves.length}: ${move.fromPath} -> ${move.toPath}`);
557
- out.taskLine(`Moving ${move.fromPath} -> ${move.toPath}`);
558
- await this.applyMoveToRemote(move, snapshot);
559
- result.filesChanged++;
560
- }
561
- catch (error) {
562
- debug(`push: move failed for ${move.fromPath}: ${error}`);
563
- result.errors.push({
564
- path: move.fromPath,
565
- operation: "move",
566
- error: error,
567
- recoverable: true,
568
- });
569
- }
570
- }
571
- // Filter to local changes only
572
- const localChanges = changes.filter(c => c.changeType === ChangeType.LOCAL_ONLY ||
573
- c.changeType === ChangeType.BOTH_CHANGED);
574
- if (localChanges.length === 0) {
575
- debug("push: no local changes to push");
576
- return result;
577
- }
578
- const newFiles = localChanges.filter(c => !snapshot.files.has(c.path) && c.localContent !== null);
579
- const modifiedFiles = localChanges.filter(c => snapshot.files.has(c.path) && c.localContent !== null);
580
- const deletedFiles = localChanges.filter(c => c.localContent === null && snapshot.files.has(c.path));
581
- debug(`push: ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`);
582
- out.update(`Pushing ${localChanges.length} local changes (${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted)`);
583
- // Group changes by parent directory path
584
- const changesByDir = new Map();
585
- for (const change of localChanges) {
586
- const pathParts = change.path.split("/");
587
- pathParts.pop(); // remove filename
588
- const dirPath = pathParts.join("/");
589
- if (!changesByDir.has(dirPath)) {
590
- changesByDir.set(dirPath, []);
591
- }
592
- changesByDir.get(dirPath).push(change);
593
- }
594
- // Collect all directory paths that need processing:
595
- // directories with file changes + all ancestors up to root
596
- const allDirsToProcess = new Set();
597
- for (const dirPath of changesByDir.keys()) {
598
- allDirsToProcess.add(dirPath);
599
- // Add ancestors so subdirectory URL updates propagate to root
600
- let current = dirPath;
601
- while (current) {
602
- const parts = current.split("/");
603
- parts.pop();
604
- current = parts.join("/");
605
- allDirsToProcess.add(current);
606
- }
607
- }
608
- // Sort deepest-first
609
- const sortedDirPaths = Array.from(allDirsToProcess).sort((a, b) => {
610
- const depthA = a ? a.split("/").length : 0;
611
- const depthB = b ? b.split("/").length : 0;
612
- return depthB - depthA;
613
- });
614
- debug(`push: processing ${sortedDirPaths.length} directories (deepest first)`);
615
- // Track which directories were modified (for subdirectory URL propagation)
616
- const modifiedDirs = new Set();
617
- let filesProcessed = 0;
618
- const totalFiles = localChanges.length;
619
- for (const dirPath of sortedDirPaths) {
620
- const dirChanges = changesByDir.get(dirPath) || [];
621
- const dirLabel = dirPath || "(root)";
622
- if (dirChanges.length > 0) {
623
- debug(`push: directory "${dirLabel}": ${dirChanges.length} file changes`);
624
- }
625
- // Ensure directory document exists
626
- if (snapshot.rootDirectoryUrl) {
627
- await this.ensureDirectoryDocument(snapshot, dirPath);
628
- }
629
- // Process all file changes in this directory
630
- const newEntries = [];
631
- const updatedEntries = [];
632
- const deletedNames = [];
633
- for (const change of dirChanges) {
634
- const fileName = change.path.split("/").pop() || "";
635
- const snapshotEntry = snapshot.files.get(change.path);
636
- filesProcessed++;
637
- try {
638
- if (change.localContent === null && snapshotEntry) {
639
- // Delete file
640
- debug(`push: [${filesProcessed}/${totalFiles}] delete ${change.path}`);
641
- out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] deleting ${change.path}`);
642
- await this.deleteRemoteFile(snapshotEntry.url, snapshot, change.path);
643
- deletedNames.push(fileName);
644
- this.snapshotManager.removeFileEntry(snapshot, change.path);
645
- result.filesChanged++;
646
- }
647
- else if (!snapshotEntry) {
648
- // New file
649
- debug(`push: [${filesProcessed}/${totalFiles}] create ${change.path} (${change.fileType})`);
650
- out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] creating ${change.path}`);
651
- const handle = await this.createRemoteFile(change);
652
- if (handle) {
653
- const entryUrl = this.getEntryUrl(handle, change.path);
654
- newEntries.push({ name: fileName, url: entryUrl });
655
- this.snapshotManager.updateFileEntry(snapshot, change.path, {
656
- path: joinAndNormalizePath(this.rootPath, change.path),
657
- url: entryUrl,
658
- head: handle.heads(),
659
- extension: getFileExtension(change.path),
660
- mimeType: getEnhancedMimeType(change.path),
661
- ...(this.isArtifactPath(change.path) && change.localContent
662
- ? { contentHash: contentHash(change.localContent) }
663
- : {}),
664
- });
665
- result.filesChanged++;
666
- debug(`push: created ${change.path} -> ${handle.url}`);
667
- }
668
- }
669
- else {
670
- // Update existing file
671
- const contentSize = typeof change.localContent === "string"
672
- ? `${change.localContent.length} chars`
673
- : `${change.localContent.length} bytes`;
674
- debug(`push: [${filesProcessed}/${totalFiles}] update ${change.path} (${contentSize})`);
675
- out.update(`Pushing local changes [${filesProcessed}/${totalFiles}] updating ${change.path}`);
676
- await this.updateRemoteFile(snapshotEntry.url, change.localContent, snapshot, change.path);
677
- // Get current entry URL (updateRemoteFile updates snapshot)
678
- const updatedFileEntry = snapshot.files.get(change.path);
679
- if (updatedFileEntry) {
680
- const fileHandle = await this.repo.find(getPlainUrl(updatedFileEntry.url));
681
- updatedEntries.push({
682
- name: fileName,
683
- url: this.getEntryUrl(fileHandle, change.path),
684
- });
685
- }
686
- result.filesChanged++;
687
- }
688
- }
689
- catch (error) {
690
- debug(`push: error processing ${change.path}: ${error}`);
691
- out.taskLine(`Error pushing ${change.path}: ${error}`, true);
692
- result.errors.push({
693
- path: change.path,
694
- operation: "local-to-remote",
695
- error: error,
696
- recoverable: true,
697
- });
698
- }
699
- }
700
- // Collect subdirectory URL updates for child dirs already processed
701
- const subdirUpdates = [];
702
- for (const modifiedDir of modifiedDirs) {
703
- // Check if modifiedDir is a direct child of dirPath
704
- const parts = modifiedDir.split("/");
705
- const childName = parts.pop() || "";
706
- const parentOfModified = parts.join("/");
707
- if (parentOfModified === dirPath) {
708
- const dirEntry = snapshot.directories.get(modifiedDir);
709
- if (dirEntry) {
710
- const childHandle = await this.repo.find(getPlainUrl(dirEntry.url));
711
- subdirUpdates.push({
712
- name: childName,
713
- url: this.getDirEntryUrl(childHandle),
714
- });
715
- }
716
- }
717
- }
718
- // Batch-update the directory document in a single change
719
- const hasChanges = newEntries.length > 0 ||
720
- updatedEntries.length > 0 ||
721
- deletedNames.length > 0 ||
722
- subdirUpdates.length > 0;
723
- if (hasChanges && snapshot.rootDirectoryUrl) {
724
- debug(`push: batch-updating directory "${dirLabel}" (+${newEntries.length} new, ~${updatedEntries.length} updated, -${deletedNames.length} deleted, ${subdirUpdates.length} subdir URL updates)`);
725
- await this.batchUpdateDirectory(snapshot, dirPath, newEntries, updatedEntries, deletedNames, subdirUpdates);
726
- modifiedDirs.add(dirPath);
727
- result.directoriesChanged++;
728
- }
729
- }
730
- debug(`push: complete - ${result.filesChanged} files, ${result.directoriesChanged} dirs changed, ${result.errors.length} errors`);
731
- return result;
732
- }
733
- /**
734
- * Phase 2: Pull remote changes to local filesystem
735
- */
736
- async pullRemoteChanges(changes, snapshot) {
737
- const result = {
738
- success: true,
739
- filesChanged: 0,
740
- directoriesChanged: 0,
741
- errors: [],
742
- warnings: [],
743
- };
744
- // Process remote changes
745
- const remoteChanges = changes.filter(c => c.changeType === ChangeType.REMOTE_ONLY ||
746
- c.changeType === ChangeType.BOTH_CHANGED);
747
- // Sort changes by dependency order (parents before children)
748
- const sortedChanges = this.sortChangesByDependency(remoteChanges);
749
- for (const change of sortedChanges) {
750
- try {
751
- await this.applyRemoteChangeToLocal(change, snapshot);
752
- result.filesChanged++;
753
- }
754
- catch (error) {
755
- result.errors.push({
756
- path: change.path,
757
- operation: "remote-to-local",
758
- error: error,
759
- recoverable: true,
760
- });
761
- }
762
- }
763
- return result;
764
- }
765
- /**
766
- * Apply remote change to local filesystem
767
- */
768
- async applyRemoteChangeToLocal(change, snapshot) {
769
- const localPath = joinAndNormalizePath(this.rootPath, change.path);
770
- if (!change.remoteHead) {
771
- throw new Error(`No remote head found for remote change to ${change.path}`);
772
- }
773
- // Check for null (empty string/Uint8Array are valid content)
774
- if (change.remoteContent === null) {
775
- // File was deleted remotely
776
- await removePath(localPath);
777
- this.snapshotManager.removeFileEntry(snapshot, change.path);
778
- return;
779
- }
780
- // Create or update local file
781
- await writeFileContent(localPath, change.remoteContent);
782
- // Update or create snapshot entry for this file
783
- const snapshotEntry = snapshot.files.get(change.path);
784
- if (snapshotEntry) {
785
- // Update existing entry
786
- snapshotEntry.head = change.remoteHead;
787
- // If the remote document was replaced (new URL), update the snapshot URL
788
- if (change.remoteUrl) {
789
- const fileHandle = await this.repo.find(change.remoteUrl);
790
- snapshotEntry.url = this.getEntryUrl(fileHandle, change.path);
791
- }
792
- }
793
- else {
794
- // Create new snapshot entry for newly discovered remote file
795
- // We need to find the remote file's URL from the directory hierarchy
796
- if (snapshot.rootDirectoryUrl) {
797
- try {
798
- const fileEntry = await findFileInDirectoryHierarchy(this.repo, snapshot.rootDirectoryUrl, change.path);
799
- if (fileEntry) {
800
- const fileHandle = await this.repo.find(fileEntry.url);
801
- const entryUrl = this.getEntryUrl(fileHandle, change.path);
802
- this.snapshotManager.updateFileEntry(snapshot, change.path, {
803
- path: localPath,
804
- url: entryUrl,
805
- head: change.remoteHead,
806
- extension: getFileExtension(change.path),
807
- mimeType: getEnhancedMimeType(change.path),
808
- });
809
- }
810
- }
811
- catch (error) {
812
- // Failed to update snapshot - file may have been deleted
813
- out.taskLine(`Warning: Failed to update snapshot for remote file ${change.path}`, true);
814
- }
815
- }
816
- }
817
- }
818
- /**
819
- * Apply move to remote documents
820
- */
821
- async applyMoveToRemote(move, snapshot) {
822
- const fromEntry = snapshot.files.get(move.fromPath);
823
- if (!fromEntry)
824
- return;
825
- // Parse paths
826
- const toParts = move.toPath.split("/");
827
- const toFileName = toParts.pop() || "";
828
- const toDirPath = toParts.join("/");
829
- // 1) Remove file entry from old directory document
830
- if (move.fromPath !== move.toPath) {
831
- await this.removeFileFromDirectory(snapshot, move.fromPath);
832
- }
833
- // 2) Ensure destination directory document exists
834
- await this.ensureDirectoryDocument(snapshot, toDirPath);
835
- // 3) Update the FileDocument name and content to match new location/state
836
- try {
837
- let entryUrl;
838
- let finalHeads;
839
- if (this.isArtifactPath(move.toPath)) {
840
- // Artifact files use RawString — no diffing needed, just create a fresh doc
841
- const content = move.newContent !== undefined
842
- ? move.newContent
843
- : readDocContent((await (await this.repo.find(getPlainUrl(fromEntry.url))).doc())?.content);
844
- const fakeChange = {
845
- path: move.toPath,
846
- changeType: ChangeType.LOCAL_ONLY,
847
- fileType: content != null && typeof content === "string" ? FileType.TEXT : FileType.BINARY,
848
- localContent: content,
849
- remoteContent: null,
850
- };
851
- const newHandle = await this.createRemoteFile(fakeChange);
852
- if (!newHandle)
853
- return;
854
- entryUrl = this.getEntryUrl(newHandle, move.toPath);
855
- finalHeads = newHandle.heads();
856
- }
857
- else {
858
- // Use plain URL for mutable handle
859
- const handle = await this.repo.find(getPlainUrl(fromEntry.url));
860
- const heads = fromEntry.head;
861
- // Update both name and content (if content changed during move)
862
- changeWithOptionalHeads(handle, heads, (doc) => {
863
- doc.name = toFileName;
864
- // If new content is provided, update it (handles move + modification case)
865
- if (move.newContent !== undefined) {
866
- if (typeof move.newContent === "string") {
867
- updateTextContent(doc, ["content"], move.newContent);
868
- }
869
- else {
870
- doc.content = move.newContent;
871
- }
872
- }
873
- });
874
- entryUrl = this.getEntryUrl(handle, move.toPath);
875
- finalHeads = handle.heads();
876
- // Track file handle for network sync
877
- this.handlesByPath.set(move.toPath, handle);
878
- }
879
- // 4) Add file entry to destination directory
880
- await this.addFileToDirectory(snapshot, move.toPath, entryUrl);
881
- // 5) Update snapshot entries
882
- this.snapshotManager.removeFileEntry(snapshot, move.fromPath);
883
- this.snapshotManager.updateFileEntry(snapshot, move.toPath, {
884
- ...fromEntry,
885
- path: joinAndNormalizePath(this.rootPath, move.toPath),
886
- url: entryUrl,
887
- head: finalHeads,
888
- ...(this.isArtifactPath(move.toPath) && move.newContent != null
889
- ? { contentHash: contentHash(move.newContent) }
890
- : {}),
891
- });
892
- }
893
- catch (e) {
894
- // Failed to update file name - file may have been deleted
895
- out.taskLine(`Warning: Failed to rename ${move.fromPath} to ${move.toPath}`, true);
896
- }
897
- }
898
- /**
899
- * Create new remote file document
900
- */
901
- async createRemoteFile(change) {
902
- if (change.localContent === null)
903
- return null;
904
- const isText = this.isTextContent(change.localContent);
905
- const isArtifact = this.isArtifactPath(change.path);
906
- // For artifact files, store text as RawString (immutable snapshot).
907
- // For regular files, store as collaborative text (empty string + splice).
908
- const fileDoc = {
909
- "@patchwork": { type: "file" },
910
- name: change.path.split("/").pop() || "",
911
- extension: getFileExtension(change.path),
912
- mimeType: getEnhancedMimeType(change.path),
913
- content: isText && isArtifact
914
- ? new A.RawString(change.localContent)
915
- : isText
916
- ? ""
917
- : change.localContent,
918
- metadata: {
919
- permissions: 0o644,
920
- },
921
- };
922
- const handle = this.repo.create(fileDoc);
923
- // For non-artifact text files, splice in the content so it's stored as collaborative text
924
- if (isText && !isArtifact && typeof change.localContent === "string") {
925
- handle.change((doc) => {
926
- updateTextContent(doc, ["content"], change.localContent);
927
- });
928
- }
929
- // Always track newly created files for network sync
930
- // (they always represent a change that needs to sync)
931
- this.handlesByPath.set(change.path, handle);
932
- return handle;
933
- }
934
- /**
935
- * Update existing remote file document
936
- */
937
- async updateRemoteFile(url, content, snapshot, filePath) {
938
- // Use plain URL for mutable handle
939
- const handle = await this.repo.find(getPlainUrl(url));
940
- // Check if content actually changed before tracking for sync
941
- const doc = await handle.doc();
942
- const rawContent = doc?.content;
943
- // For artifact paths, always replace with a new document containing RawString.
944
- // For non-artifact paths with immutable strings, replace with mutable text.
945
- // In both cases we create a new document and update the snapshot URL.
946
- const isArtifact = this.isArtifactPath(filePath);
947
- if (isArtifact ||
948
- !doc ||
949
- (rawContent != null && A.isImmutableString(rawContent))) {
950
- if (!isArtifact) {
951
- out.taskLine(`Replacing ${!doc ? 'unavailable' : 'immutable string'} document for ${filePath}`, true);
952
- }
953
- const fakeChange = {
954
- path: filePath,
955
- changeType: ChangeType.LOCAL_ONLY,
956
- fileType: this.isTextContent(content)
957
- ? FileType.TEXT
958
- : FileType.BINARY,
959
- localContent: content,
960
- remoteContent: null,
961
- };
962
- const newHandle = await this.createRemoteFile(fakeChange);
963
- if (newHandle) {
964
- const entryUrl = this.getEntryUrl(newHandle, filePath);
965
- this.snapshotManager.updateFileEntry(snapshot, filePath, {
966
- path: joinAndNormalizePath(this.rootPath, filePath),
967
- url: entryUrl,
968
- head: newHandle.heads(),
969
- extension: getFileExtension(filePath),
970
- mimeType: getEnhancedMimeType(filePath),
971
- ...(this.isArtifactPath(filePath)
972
- ? { contentHash: contentHash(content) }
973
- : {}),
974
- });
975
- }
976
- return;
977
- }
978
- const currentContent = readDocContent(rawContent);
979
- const contentChanged = !isContentEqual(content, currentContent);
980
- // Update snapshot heads even when content is identical
981
- const snapshotEntry = snapshot.files.get(filePath);
982
- if (snapshotEntry) {
983
- // Update snapshot with current document heads
984
- snapshot.files.set(filePath, {
985
- ...snapshotEntry,
986
- head: handle.heads(),
987
- });
988
- }
989
- if (!contentChanged) {
990
- // Content is identical, but we've updated the snapshot heads above
991
- // This prevents fresh change detection from seeing stale heads
992
- return;
993
- }
994
- const heads = snapshotEntry?.head;
995
- if (!heads) {
996
- throw new Error(`No heads found for ${url}`);
997
- }
998
- handle.changeAt(heads, (doc) => {
999
- if (typeof content === "string") {
1000
- updateTextContent(doc, ["content"], content);
1001
- }
1002
- else {
1003
- doc.content = content;
1004
- }
1005
- });
1006
- // Update snapshot with new heads after content change
1007
- if (snapshotEntry) {
1008
- snapshot.files.set(filePath, {
1009
- ...snapshotEntry,
1010
- head: handle.heads(),
1011
- });
1012
- }
1013
- // Only track files that actually changed content
1014
- this.handlesByPath.set(filePath, handle);
1015
- }
1016
- /**
1017
- * Delete remote file document
1018
- */
1019
- async deleteRemoteFile(_url, _snapshot, _filePath) {
1020
- // In Automerge, we don't actually delete documents.
1021
- // The file entry is removed from its parent directory, making the
1022
- // document orphaned. Clearing content via splice is expensive for
1023
- // large text files (every character is a CRDT op), so we skip it.
1024
- }
1025
- /**
1026
- * Add file entry to appropriate directory document (maintains hierarchy)
1027
- */
1028
- async addFileToDirectory(snapshot, filePath, fileUrl) {
1029
- if (!snapshot.rootDirectoryUrl)
1030
- return;
1031
- const pathParts = filePath.split("/");
1032
- const fileName = pathParts.pop() || "";
1033
- const directoryPath = pathParts.join("/");
1034
- // Get or create the parent directory document
1035
- const parentDirUrl = await this.ensureDirectoryDocument(snapshot, directoryPath);
1036
- // Use plain URL for mutable handle
1037
- const dirHandle = await this.repo.find(getPlainUrl(parentDirUrl));
1038
- let didChange = false;
1039
- const snapshotEntry = snapshot.directories.get(directoryPath);
1040
- const heads = snapshotEntry?.head;
1041
- changeWithOptionalHeads(dirHandle, heads, (doc) => {
1042
- const existingIndex = doc.docs.findIndex(entry => entry.name === fileName && entry.type === "file");
1043
- if (existingIndex === -1) {
1044
- doc.docs.push({
1045
- name: fileName,
1046
- type: "file",
1047
- url: fileUrl,
1048
- });
1049
- didChange = true;
1050
- }
1051
- });
1052
- // Always track the directory (even if unchanged) for proper leaf-first sync ordering
1053
- this.handlesByPath.set(directoryPath, dirHandle);
1054
- if (didChange && snapshotEntry) {
1055
- snapshotEntry.head = dirHandle.heads();
1056
- }
1057
- }
1058
- /**
1059
- * Ensure directory document exists for the given path, creating hierarchy as needed
1060
- * First checks for existing shared directories before creating new ones
1061
- */
1062
- async ensureDirectoryDocument(snapshot, directoryPath) {
1063
- // Root directory case
1064
- if (!directoryPath || directoryPath === "") {
1065
- return snapshot.rootDirectoryUrl;
1066
- }
1067
- // Check if we already have this directory in snapshot
1068
- const existingDir = snapshot.directories.get(directoryPath);
1069
- if (existingDir) {
1070
- return existingDir.url;
1071
- }
1072
- // Split path into parent and current directory name
1073
- const pathParts = directoryPath.split("/");
1074
- const currentDirName = pathParts.pop() || "";
1075
- const parentPath = pathParts.join("/");
1076
- // Ensure parent directory exists first (recursive)
1077
- const parentDirUrl = await this.ensureDirectoryDocument(snapshot, parentPath);
1078
- // DISCOVERY: Check if directory already exists in parent on server
1079
- try {
1080
- const parentHandle = await this.repo.find(parentDirUrl);
1081
- const parentDoc = await parentHandle.doc();
1082
- if (parentDoc) {
1083
- const existingDirEntry = parentDoc.docs.find((entry) => entry.name === currentDirName && entry.type === "folder");
1084
- if (existingDirEntry) {
1085
- // Resolve the actual directory handle and use its current heads
1086
- // Directory entries in parent docs may not carry valid heads
1087
- try {
1088
- const childDirHandle = await this.repo.find(existingDirEntry.url);
1089
- // Track discovered directory for sync
1090
- this.handlesByPath.set(directoryPath, childDirHandle);
1091
- // Get appropriate URL for directory entry
1092
- const entryUrl = this.getDirEntryUrl(childDirHandle);
1093
- // Update snapshot with discovered directory
1094
- this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1095
- path: joinAndNormalizePath(this.rootPath, directoryPath),
1096
- url: entryUrl,
1097
- head: childDirHandle.heads(),
1098
- entries: [],
1099
- });
1100
- return entryUrl;
1101
- }
1102
- catch (resolveErr) {
1103
- // Failed to resolve directory - fall through to create a fresh directory document
1104
- }
1105
- }
1106
- }
1107
- }
1108
- catch (error) {
1109
- // Failed to check for existing directory - will create new one
1110
- }
1111
- // CREATE: Directory doesn't exist, create new one
1112
- const dirDoc = {
1113
- "@patchwork": { type: "folder" },
1114
- name: currentDirName,
1115
- title: currentDirName,
1116
- docs: [],
1117
- };
1118
- const dirHandle = this.repo.create(dirDoc);
1119
- // Get appropriate URL for directory entry
1120
- const dirEntryUrl = this.getDirEntryUrl(dirHandle);
1121
- // Add this directory to its parent
1122
- // Use plain URL for mutable handle
1123
- const parentHandle = await this.repo.find(getPlainUrl(parentDirUrl));
1124
- let didChange = false;
1125
- parentHandle.change((doc) => {
1126
- // Double-check that entry doesn't exist (race condition protection)
1127
- const existingIndex = doc.docs.findIndex((entry) => entry.name === currentDirName && entry.type === "folder");
1128
- if (existingIndex === -1) {
1129
- doc.docs.push({
1130
- name: currentDirName,
1131
- type: "folder",
1132
- url: dirEntryUrl,
1133
- });
1134
- didChange = true;
1135
- }
1136
- });
1137
- // Track directory handles for sync
1138
- this.handlesByPath.set(directoryPath, dirHandle);
1139
- if (didChange) {
1140
- this.handlesByPath.set(parentPath, parentHandle);
1141
- const parentSnapshotEntry = snapshot.directories.get(parentPath);
1142
- if (parentSnapshotEntry) {
1143
- parentSnapshotEntry.head = parentHandle.heads();
1144
- }
1145
- }
1146
- // Update snapshot with new directory
1147
- this.snapshotManager.updateDirectoryEntry(snapshot, directoryPath, {
1148
- path: joinAndNormalizePath(this.rootPath, directoryPath),
1149
- url: dirEntryUrl,
1150
- head: dirHandle.heads(),
1151
- entries: [],
1152
- });
1153
- return dirEntryUrl;
1154
- }
1155
- /**
1156
- * Remove file entry from directory document
1157
- */
1158
- async removeFileFromDirectory(snapshot, filePath) {
1159
- if (!snapshot.rootDirectoryUrl)
1160
- return;
1161
- const pathParts = filePath.split("/");
1162
- const fileName = pathParts.pop() || "";
1163
- const directoryPath = pathParts.join("/");
1164
- // Get the parent directory URL
1165
- let parentDirUrl;
1166
- if (!directoryPath || directoryPath === "") {
1167
- parentDirUrl = snapshot.rootDirectoryUrl;
1168
- }
1169
- else {
1170
- const existingDir = snapshot.directories.get(directoryPath);
1171
- if (!existingDir) {
1172
- // Directory not found - file may already be removed
1173
- return;
1174
- }
1175
- parentDirUrl = existingDir.url;
1176
- }
1177
- try {
1178
- // Use plain URL for mutable handle
1179
- const dirHandle = await this.repo.find(getPlainUrl(parentDirUrl));
1180
- // Track this handle for network sync waiting
1181
- this.handlesByPath.set(directoryPath, dirHandle);
1182
- const snapshotEntry = snapshot.directories.get(directoryPath);
1183
- const heads = snapshotEntry?.head;
1184
- let didChange = false;
1185
- changeWithOptionalHeads(dirHandle, heads, (doc) => {
1186
- const indexToRemove = doc.docs.findIndex(entry => entry.name === fileName && entry.type === "file");
1187
- if (indexToRemove !== -1) {
1188
- doc.docs.splice(indexToRemove, 1);
1189
- didChange = true;
1190
- out.taskLine(`Removed ${fileName} from ${formatRelativePath(directoryPath) || "root"}`);
1191
- }
1192
- });
1193
- if (didChange && snapshotEntry) {
1194
- snapshotEntry.head = dirHandle.heads();
1195
- }
1196
- }
1197
- catch (error) {
1198
- throw error;
1199
- }
1200
- }
1201
- /**
1202
- * Batch-update a directory document in a single change: add new file entries,
1203
- * update URLs for modified files, remove deleted entries, and update
1204
- * subdirectory URLs. This replaces the separate per-file directory mutations
1205
- * and the post-hoc URL update pass.
1206
- */
1207
- async batchUpdateDirectory(snapshot, dirPath, newEntries, updatedEntries, deletedNames, subdirUpdates) {
1208
- let dirUrl;
1209
- if (!dirPath || dirPath === "") {
1210
- dirUrl = snapshot.rootDirectoryUrl;
1211
- }
1212
- else {
1213
- const dirEntry = snapshot.directories.get(dirPath);
1214
- if (!dirEntry)
1215
- return;
1216
- dirUrl = dirEntry.url;
1217
- }
1218
- const dirHandle = await this.repo.find(getPlainUrl(dirUrl));
1219
- const snapshotEntry = snapshot.directories.get(dirPath);
1220
- const heads = snapshotEntry?.head;
1221
- // Determine directory name
1222
- const dirName = dirPath ? dirPath.split("/").pop() || "" : path.basename(this.rootPath);
1223
- changeWithOptionalHeads(dirHandle, heads, (doc) => {
1224
- // Ensure name and title fields are set
1225
- if (!doc.name)
1226
- doc.name = dirName;
1227
- if (!doc.title)
1228
- doc.title = dirName;
1229
- // Remove deleted file entries
1230
- for (const name of deletedNames) {
1231
- const idx = doc.docs.findIndex(entry => entry.name === name && entry.type === "file");
1232
- if (idx !== -1) {
1233
- doc.docs.splice(idx, 1);
1234
- out.taskLine(`Removed ${name} from ${formatRelativePath(dirPath) || "root"}`);
1235
- }
1236
- }
1237
- // Update URLs for modified files
1238
- for (const { name, url } of updatedEntries) {
1239
- const idx = doc.docs.findIndex(entry => entry.name === name && entry.type === "file");
1240
- if (idx !== -1) {
1241
- doc.docs[idx].url = url;
1242
- }
1243
- }
1244
- // Add new file entries
1245
- for (const { name, url } of newEntries) {
1246
- const existing = doc.docs.findIndex(entry => entry.name === name && entry.type === "file");
1247
- if (existing === -1) {
1248
- doc.docs.push({ name, type: "file", url });
1249
- }
1250
- else {
1251
- // Entry already exists (e.g. from immutable string replacement)
1252
- doc.docs[existing].url = url;
1253
- }
1254
- }
1255
- // Update subdirectory URLs with current heads
1256
- for (const { name, url } of subdirUpdates) {
1257
- const idx = doc.docs.findIndex(entry => entry.name === name && entry.type === "folder");
1258
- if (idx !== -1) {
1259
- doc.docs[idx].url = url;
1260
- }
1261
- }
1262
- });
1263
- // Track directory handle and update snapshot heads
1264
- this.handlesByPath.set(dirPath, dirHandle);
1265
- if (snapshotEntry) {
1266
- snapshotEntry.head = dirHandle.heads();
1267
- }
1268
- }
1269
- /**
1270
- * Sort changes by dependency order
1271
- */
1272
- sortChangesByDependency(changes) {
1273
- // Sort by path depth (shallower paths first)
1274
- return changes.sort((a, b) => {
1275
- const depthA = a.path.split("/").length;
1276
- const depthB = b.path.split("/").length;
1277
- return depthA - depthB;
1278
- });
1279
- }
1280
- /**
1281
- * Get sync status
1282
- */
1283
- async getStatus() {
1284
- const snapshot = await this.snapshotManager.load();
1285
- if (!snapshot) {
1286
- return {
1287
- snapshot: null,
1288
- hasChanges: false,
1289
- changeCount: 0,
1290
- lastSync: null,
1291
- };
1292
- }
1293
- const changes = await this.changeDetector.detectChanges(snapshot);
1294
- return {
1295
- snapshot,
1296
- hasChanges: changes.length > 0,
1297
- changeCount: changes.length,
1298
- lastSync: new Date(snapshot.timestamp),
1299
- };
1300
- }
1301
- /**
1302
- * Preview changes without applying them
1303
- */
1304
- async previewChanges() {
1305
- const snapshot = await this.snapshotManager.load();
1306
- if (!snapshot) {
1307
- return {
1308
- changes: [],
1309
- moves: [],
1310
- summary: "No snapshot found - run init first",
1311
- };
1312
- }
1313
- const changes = await this.changeDetector.detectChanges(snapshot);
1314
- const { moves } = await this.moveDetector.detectMoves(changes, snapshot);
1315
- const summary = this.generateChangeSummary(changes, moves);
1316
- return { changes, moves, summary };
1317
- }
1318
- /**
1319
- * Generate human-readable summary of changes
1320
- */
1321
- generateChangeSummary(changes, moves) {
1322
- const localChanges = changes.filter(c => c.changeType === ChangeType.LOCAL_ONLY ||
1323
- c.changeType === ChangeType.BOTH_CHANGED).length;
1324
- const remoteChanges = changes.filter(c => c.changeType === ChangeType.REMOTE_ONLY ||
1325
- c.changeType === ChangeType.BOTH_CHANGED).length;
1326
- const conflicts = changes.filter(c => c.changeType === ChangeType.BOTH_CHANGED).length;
1327
- const parts = [];
1328
- if (localChanges > 0) {
1329
- parts.push(`${localChanges} local change${localChanges > 1 ? "s" : ""}`);
1330
- }
1331
- if (remoteChanges > 0) {
1332
- parts.push(`${remoteChanges} remote change${remoteChanges > 1 ? "s" : ""}`);
1333
- }
1334
- if (moves.length > 0) {
1335
- parts.push(`${moves.length} potential move${moves.length > 1 ? "s" : ""}`);
1336
- }
1337
- if (conflicts > 0) {
1338
- parts.push(`${conflicts} conflict${conflicts > 1 ? "s" : ""}`);
1339
- }
1340
- if (parts.length === 0) {
1341
- return "No changes detected";
1342
- }
1343
- return parts.join(", ");
1344
- }
1345
- /**
1346
- * Update the lastSyncAt timestamp on the root directory document
1347
- */
1348
- async touchRootDirectory(snapshot) {
1349
- if (!snapshot.rootDirectoryUrl) {
1350
- return;
1351
- }
1352
- try {
1353
- const rootHandle = await this.repo.find(snapshot.rootDirectoryUrl);
1354
- const timestamp = Date.now();
1355
- let version;
1356
- try {
1357
- version = require("../../package.json").version;
1358
- }
1359
- catch {
1360
- version = "unknown";
1361
- }
1362
- debug(`touchRootDirectory: setting lastSyncAt=${timestamp} with=pushwork@${version}`);
1363
- rootHandle.change((doc) => {
1364
- doc.lastSyncAt = timestamp;
1365
- doc.with = `pushwork@${version}`;
1366
- });
1367
- // Track root directory for network sync
1368
- this.handlesByPath.set("", rootHandle);
1369
- const snapshotEntry = snapshot.directories.get("");
1370
- if (snapshotEntry) {
1371
- snapshotEntry.head = rootHandle.heads();
1372
- }
1373
- }
1374
- catch (error) {
1375
- debug(`touchRootDirectory: failed: ${error}`);
1376
- }
1377
- }
1378
- }
1379
- //# sourceMappingURL=sync-engine.js.map