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,712 +0,0 @@
1
- import {
2
- AutomergeUrl,
3
- DocHandle,
4
- Repo,
5
- UrlHeads,
6
- } from "@automerge/automerge-repo"
7
- import * as A from "@automerge/automerge"
8
- import {
9
- ChangeType,
10
- FileType,
11
- SyncSnapshot,
12
- FileDocument,
13
- DirectoryDocument,
14
- DetectedChange,
15
- } from "../types/index.js"
16
- import {
17
- readFileContent,
18
- listDirectory,
19
- getRelativePath,
20
- findFileInDirectoryHierarchy,
21
- joinAndNormalizePath,
22
- getPlainUrl,
23
- readDocContent,
24
- } from "../utils/index.js"
25
- import {isContentEqual, contentHash} from "../utils/content.js"
26
- import {out} from "../utils/output.js"
27
-
28
- const isDebug = !!process.env.DEBUG
29
- function debug(...args: any[]) {
30
- if (isDebug) console.error("[pushwork:change-detection]", ...args)
31
- }
32
-
33
- /**
34
- * Change detection engine
35
- */
36
- export class ChangeDetector {
37
- constructor(
38
- private repo: Repo,
39
- private rootPath: string,
40
- private excludePatterns: string[] = [],
41
- private artifactDirectories: string[] = []
42
- ) {}
43
-
44
- /**
45
- * Check if a file path is inside an artifact directory.
46
- * Artifact files use RawString and are always replaced wholesale,
47
- * so we can skip expensive remote content reads for them.
48
- */
49
- private isArtifactPath(filePath: string): boolean {
50
- return this.artifactDirectories.some(
51
- dir => filePath === dir || filePath.startsWith(dir + "/")
52
- )
53
- }
54
-
55
- /**
56
- * Detect all changes between local filesystem and snapshot
57
- */
58
- async detectChanges(snapshot: SyncSnapshot, excludePaths?: Set<string>): Promise<DetectedChange[]> {
59
- const changes: DetectedChange[] = []
60
-
61
- // Get current filesystem state
62
- const currentFiles = await this.getCurrentFilesystemState()
63
-
64
- // Check for local changes (new, modified, deleted files)
65
- const localChanges = await this.detectLocalChanges(snapshot, currentFiles)
66
- changes.push(...localChanges)
67
-
68
- // Check for remote changes (changes in Automerge documents)
69
- const remoteChanges = await this.detectRemoteChanges(snapshot)
70
- changes.push(...remoteChanges)
71
-
72
- // Check for new remote documents not in snapshot (critical for clone scenarios)
73
- const newRemoteDocuments = await this.detectNewRemoteDocuments(snapshot, excludePaths)
74
- changes.push(...newRemoteDocuments)
75
-
76
- return changes
77
- }
78
-
79
- /**
80
- * Detect changes in local filesystem compared to snapshot
81
- */
82
- private async detectLocalChanges(
83
- snapshot: SyncSnapshot,
84
- currentFiles: Map<string, {content: string | Uint8Array; type: FileType}>
85
- ): Promise<DetectedChange[]> {
86
- const changes: DetectedChange[] = []
87
-
88
- // Check for new and modified files in parallel for better performance
89
- await Promise.all(
90
- Array.from(currentFiles.entries()).map(
91
- async ([relativePath, fileInfo]) => {
92
- const snapshotEntry = snapshot.files.get(relativePath)
93
-
94
- if (!snapshotEntry) {
95
- // New file
96
- changes.push({
97
- path: relativePath,
98
- changeType: ChangeType.LOCAL_ONLY,
99
- fileType: fileInfo.type,
100
- localContent: fileInfo.content,
101
- remoteContent: null,
102
- })
103
- } else if (this.isArtifactPath(relativePath)) {
104
- // Artifact files are always replaced wholesale (RawString).
105
- // Skip remote doc content reads — compare local hash against
106
- // stored hash to detect local changes, and check heads for remote.
107
- const localHash = contentHash(fileInfo.content)
108
- const localChanged = snapshotEntry.contentHash
109
- ? localHash !== snapshotEntry.contentHash
110
- : true // No stored hash = first sync with hash support, assume changed
111
-
112
- const remoteHead = await this.getCurrentRemoteHead(
113
- snapshotEntry.url
114
- )
115
- const remoteChanged = !A.equals(remoteHead, snapshotEntry.head)
116
-
117
- if (localChanged || remoteChanged) {
118
- changes.push({
119
- path: relativePath,
120
- changeType:
121
- localChanged && remoteChanged
122
- ? ChangeType.BOTH_CHANGED
123
- : localChanged
124
- ? ChangeType.LOCAL_ONLY
125
- : ChangeType.REMOTE_ONLY,
126
- fileType: fileInfo.type,
127
- localContent: fileInfo.content,
128
- remoteContent: null,
129
- localHead: snapshotEntry.head,
130
- remoteHead,
131
- })
132
- }
133
- } else {
134
- // Check if content changed
135
- const lastKnownContent = await this.getContentAtHead(
136
- snapshotEntry.url,
137
- snapshotEntry.head
138
- )
139
-
140
- const contentChanged = !isContentEqual(
141
- fileInfo.content,
142
- lastKnownContent
143
- )
144
-
145
- if (contentChanged) {
146
- // Check remote state too
147
- const currentRemoteContent = await this.getCurrentRemoteContent(
148
- snapshotEntry.url
149
- )
150
-
151
- const remoteChanged = !isContentEqual(
152
- lastKnownContent,
153
- currentRemoteContent
154
- )
155
-
156
- const changeType = remoteChanged
157
- ? ChangeType.BOTH_CHANGED
158
- : ChangeType.LOCAL_ONLY
159
-
160
- const remoteHead = await this.getCurrentRemoteHead(
161
- snapshotEntry.url
162
- )
163
-
164
- changes.push({
165
- path: relativePath,
166
- changeType,
167
- fileType: fileInfo.type,
168
- localContent: fileInfo.content,
169
- remoteContent: currentRemoteContent,
170
- localHead: snapshotEntry.head,
171
- remoteHead,
172
- })
173
- }
174
- }
175
- }
176
- )
177
- )
178
-
179
- // Check for deleted files in parallel
180
- await Promise.all(
181
- Array.from(snapshot.files.entries())
182
- .filter(([relativePath]) => !currentFiles.has(relativePath))
183
- .map(async ([relativePath, snapshotEntry]) => {
184
- if (this.isArtifactPath(relativePath)) {
185
- // Artifact deletion: skip remote content read
186
- const remoteHead = await this.getCurrentRemoteHead(
187
- snapshotEntry.url
188
- )
189
- const remoteChanged = !A.equals(remoteHead, snapshotEntry.head)
190
-
191
- changes.push({
192
- path: relativePath,
193
- changeType: remoteChanged
194
- ? ChangeType.BOTH_CHANGED
195
- : ChangeType.LOCAL_ONLY,
196
- fileType: FileType.TEXT,
197
- localContent: null,
198
- remoteContent: null,
199
- localHead: snapshotEntry.head,
200
- remoteHead,
201
- })
202
- return
203
- }
204
-
205
- // File was deleted locally
206
- const currentRemoteContent = await this.getCurrentRemoteContent(
207
- snapshotEntry.url
208
- )
209
- const lastKnownContent = await this.getContentAtHead(
210
- snapshotEntry.url,
211
- snapshotEntry.head
212
- )
213
-
214
- const remoteChanged = !isContentEqual(
215
- lastKnownContent,
216
- currentRemoteContent
217
- )
218
-
219
- const changeType = remoteChanged
220
- ? ChangeType.BOTH_CHANGED
221
- : ChangeType.LOCAL_ONLY
222
-
223
- changes.push({
224
- path: relativePath,
225
- changeType,
226
- fileType: FileType.TEXT, // Will be determined from document
227
- localContent: null,
228
- remoteContent: currentRemoteContent,
229
- localHead: snapshotEntry.head,
230
- remoteHead: await this.getCurrentRemoteHead(snapshotEntry.url),
231
- })
232
- })
233
- )
234
-
235
- return changes
236
- }
237
-
238
- /**
239
- * Detect changes in remote Automerge documents compared to snapshot
240
- */
241
- private async detectRemoteChanges(
242
- snapshot: SyncSnapshot
243
- ): Promise<DetectedChange[]> {
244
- const changes: DetectedChange[] = []
245
-
246
- await Promise.all(
247
- Array.from(snapshot.files.entries()).map(
248
- async ([relativePath, snapshotEntry]) => {
249
- // Find the file's current entry in the remote directory hierarchy
250
- const remoteEntry = await this.findInRemoteDirectory(
251
- snapshot.rootDirectoryUrl,
252
- relativePath
253
- )
254
-
255
- if (!remoteEntry) {
256
- // File was removed from remote directory listing
257
- const localContent = await this.getLocalContent(relativePath)
258
-
259
- // Only report as deleted if local file still exists
260
- // (if local file is also deleted, detectLocalChanges handles it)
261
- if (localContent !== null) {
262
- changes.push({
263
- path: relativePath,
264
- changeType: ChangeType.REMOTE_ONLY,
265
- fileType: FileType.TEXT,
266
- localContent,
267
- remoteContent: null, // File deleted remotely
268
- localHead: snapshotEntry.head,
269
- remoteHead: snapshotEntry.head,
270
- })
271
- }
272
- return
273
- }
274
-
275
- // Check if the document was replaced entirely (new URL).
276
- // This happens when a peer replaces an artifact file, fixes a
277
- // legacy immutable string, or recreates a failed document.
278
- // The old snapshot URL is now orphaned — read from the new one.
279
- const urlReplaced = getPlainUrl(remoteEntry.url) !== getPlainUrl(snapshotEntry.url)
280
- const remoteUrl = urlReplaced ? remoteEntry.url : snapshotEntry.url
281
-
282
- const currentRemoteHead = await this.getCurrentRemoteHead(remoteUrl)
283
-
284
- if (urlReplaced || !A.equals(currentRemoteHead, snapshotEntry.head)) {
285
- if (this.isArtifactPath(relativePath)) {
286
- // Artifact: skip content reads, just report head change
287
- const localContent = await this.getLocalContent(relativePath)
288
- changes.push({
289
- path: relativePath,
290
- changeType:
291
- localContent !== null
292
- ? ChangeType.BOTH_CHANGED
293
- : ChangeType.REMOTE_ONLY,
294
- fileType: FileType.TEXT,
295
- localContent,
296
- remoteContent: null,
297
- localHead: snapshotEntry.head,
298
- remoteHead: currentRemoteHead,
299
- ...(urlReplaced ? {remoteUrl: remoteEntry.url} : {}),
300
- })
301
- return
302
- }
303
-
304
- // Remote document has changed
305
- const currentRemoteContent = await this.getCurrentRemoteContent(remoteUrl)
306
- const localContent = await this.getLocalContent(relativePath)
307
- const lastKnownContent = urlReplaced
308
- ? null // Can't diff against old doc when URL changed
309
- : await this.getContentAtHead(
310
- snapshotEntry.url,
311
- snapshotEntry.head
312
- )
313
-
314
- const localChanged = localContent && lastKnownContent
315
- ? !isContentEqual(localContent, lastKnownContent)
316
- : localContent !== null
317
-
318
- const changeType = localChanged
319
- ? ChangeType.BOTH_CHANGED
320
- : ChangeType.REMOTE_ONLY
321
-
322
- changes.push({
323
- path: relativePath,
324
- changeType,
325
- fileType: await this.getFileTypeFromContent(currentRemoteContent),
326
- localContent,
327
- remoteContent: currentRemoteContent,
328
- localHead: snapshotEntry.head,
329
- remoteHead: currentRemoteHead,
330
- ...(urlReplaced ? {remoteUrl: remoteEntry.url} : {}),
331
- })
332
- }
333
- }
334
- )
335
- )
336
-
337
- return changes
338
- }
339
-
340
- /**
341
- * Detect new remote documents from directory hierarchy that aren't in snapshot
342
- * This is critical for clone scenarios where local snapshot is empty
343
- */
344
- private async detectNewRemoteDocuments(
345
- snapshot: SyncSnapshot,
346
- excludePaths?: Set<string>
347
- ): Promise<DetectedChange[]> {
348
- const changes: DetectedChange[] = []
349
-
350
- // If no root directory URL, nothing to discover
351
- if (!snapshot.rootDirectoryUrl) {
352
- return changes
353
- }
354
-
355
- try {
356
- // Recursively traverse the directory hierarchy
357
- await this.discoverRemoteDocumentsRecursive(
358
- snapshot.rootDirectoryUrl,
359
- "",
360
- snapshot,
361
- changes,
362
- excludePaths
363
- )
364
- } catch (error) {
365
- out.taskLine(`Failed to discover remote documents: ${error}`, true)
366
- }
367
-
368
- return changes
369
- }
370
-
371
- /**
372
- * Recursively discover remote documents in directory hierarchy
373
- */
374
- private async discoverRemoteDocumentsRecursive(
375
- directoryUrl: AutomergeUrl,
376
- currentPath: string,
377
- snapshot: SyncSnapshot,
378
- changes: DetectedChange[],
379
- excludePaths?: Set<string>
380
- ): Promise<void> {
381
- try {
382
- // Find and wait for document to be available (retries on "unavailable")
383
- const plainUrl = getPlainUrl(directoryUrl)
384
- const result = await this.findDocument<DirectoryDocument>(plainUrl)
385
-
386
- if (!result) {
387
- return
388
- }
389
- const dirDoc = result.doc
390
-
391
- // Process each entry in the directory
392
- for (const entry of dirDoc.docs) {
393
- const entryPath = currentPath
394
- ? `${currentPath}/${entry.name}`
395
- : entry.name
396
-
397
- if (entry.type === "file") {
398
- // Skip files that were deliberately deleted during this sync cycle
399
- if (excludePaths?.has(entryPath)) {
400
- debug(`skipping deleted path during re-detection: ${entryPath}`)
401
- continue
402
- }
403
-
404
- // Check if this file is already tracked in the snapshot
405
- const existingEntry = snapshot.files.get(entryPath)
406
-
407
- if (!existingEntry) {
408
- // This is a remote file not in our snapshot
409
- const localContent = await this.getLocalContent(entryPath)
410
- const remoteContent = await this.getCurrentRemoteContent(entry.url)
411
- const remoteHead = await this.getCurrentRemoteHead(entry.url)
412
-
413
- if (localContent && remoteContent) {
414
- // File exists both locally and remotely but not in snapshot
415
- changes.push({
416
- path: entryPath,
417
- changeType: ChangeType.BOTH_CHANGED,
418
- fileType: await this.getFileTypeFromContent(remoteContent),
419
- localContent,
420
- remoteContent,
421
- remoteHead,
422
- })
423
- } else if (localContent !== null && remoteContent === null) {
424
- // File exists locally but not remotely (shouldn't happen in this flow)
425
- changes.push({
426
- path: entryPath,
427
- changeType: ChangeType.LOCAL_ONLY,
428
- fileType: await this.getFileTypeFromContent(localContent),
429
- localContent,
430
- remoteContent: null,
431
- })
432
- } else if (localContent === null && remoteContent !== null) {
433
- // File exists remotely but not locally - this is what we need for clone!
434
- changes.push({
435
- path: entryPath,
436
- changeType: ChangeType.REMOTE_ONLY,
437
- fileType: await this.getFileTypeFromContent(remoteContent),
438
- localContent: null,
439
- remoteContent,
440
- remoteHead,
441
- })
442
- }
443
- // Only ignore if neither local nor remote content exists (ghost entry)
444
- } else if (
445
- getPlainUrl(entry.url) !== getPlainUrl(existingEntry.url)
446
- ) {
447
- // HACK: URL replacement detection bolted onto the "discover new docs" walk.
448
- //
449
- // A peer can replace a document entirely (creating a new URL) rather than mutating
450
- // the existing one. This happens in several cases in updateRemoteFile(): artifact
451
- // paths are always replaced; non-artifact docs with legacy immutable string content
452
- // are also replaced; and recreateFailedDocuments() replaces docs that timed out
453
- // during network sync. The two normal remote-change scans both miss this:
454
- // - detectRemoteChanges() is snapshot-centric: it checks the old (now orphaned)
455
- // doc's heads, which haven't changed, so it reports no change.
456
- // - The "new doc" branch above is directory-centric: it skips paths already in
457
- // the snapshot, assuming they're handled by detectRemoteChanges().
458
- //
459
- // A cleaner fix would be to have detectRemoteChanges() also verify that the
460
- // directory still points to the same URL for each snapshot entry, treating a
461
- // mismatch as a first-class URL-replacement change rather than a special case here.
462
- const localContent = await this.getLocalContent(entryPath)
463
- const remoteContent = await this.getCurrentRemoteContent(entry.url)
464
- const remoteHead = await this.getCurrentRemoteHead(entry.url)
465
-
466
- if (remoteContent !== null) {
467
- changes.push({
468
- path: entryPath,
469
- changeType:
470
- localContent !== null
471
- ? ChangeType.BOTH_CHANGED
472
- : ChangeType.REMOTE_ONLY,
473
- fileType: await this.getFileTypeFromContent(remoteContent),
474
- localContent: localContent ?? null,
475
- remoteContent,
476
- remoteHead,
477
- remoteUrl: entry.url,
478
- })
479
- }
480
- }
481
- } else if (entry.type === "folder") {
482
- // Recursively process subdirectory
483
- await this.discoverRemoteDocumentsRecursive(
484
- entry.url,
485
- entryPath,
486
- snapshot,
487
- changes,
488
- excludePaths
489
- )
490
- }
491
- }
492
- } catch (error) {
493
- out.taskLine(`Failed to process directory: ${error}`, true)
494
- }
495
- }
496
-
497
- /**
498
- * Get current filesystem state as a map
499
- */
500
- private async getCurrentFilesystemState(): Promise<
501
- Map<string, {content: string | Uint8Array; type: FileType}>
502
- > {
503
- const fileMap = new Map<
504
- string,
505
- {content: string | Uint8Array; type: FileType}
506
- >()
507
-
508
- try {
509
- const entries = await listDirectory(
510
- this.rootPath,
511
- true,
512
- this.excludePatterns
513
- )
514
-
515
- const fileEntries = entries.filter(
516
- entry => entry.type !== FileType.DIRECTORY
517
- )
518
-
519
- await Promise.all(
520
- fileEntries.map(async entry => {
521
- const relativePath = getRelativePath(this.rootPath, entry.path)
522
- const content = await readFileContent(entry.path)
523
-
524
- fileMap.set(relativePath, {content, type: entry.type})
525
- })
526
- )
527
- } catch (error) {
528
- out.taskLine(`Failed to scan filesystem: ${error}`, true)
529
- // Log more details about the error
530
- if (error instanceof Error) {
531
- out.taskLine(`Error details: ${error.message}`, true)
532
- if (error.stack) {
533
- out.taskLine(`Stack: ${error.stack}`, true)
534
- }
535
- }
536
- }
537
-
538
- return fileMap
539
- }
540
-
541
- /**
542
- * Get local file content if it exists
543
- */
544
- private async getLocalContent(
545
- relativePath: string
546
- ): Promise<string | Uint8Array | null> {
547
- try {
548
- const fullPath = joinAndNormalizePath(this.rootPath, relativePath)
549
- return await readFileContent(fullPath)
550
- } catch {
551
- return null
552
- }
553
- }
554
-
555
- /**
556
- * Get content from Automerge document at specific head
557
- */
558
- private async getContentAtHead(
559
- url: AutomergeUrl,
560
- heads: UrlHeads
561
- ): Promise<string | Uint8Array | null> {
562
- try {
563
- // Strip heads for current document state
564
- const plainUrl = getPlainUrl(url)
565
- const handle = await this.repo.find<FileDocument>(plainUrl)
566
- const doc = await handle.view(heads).doc()
567
-
568
- const content = (doc as FileDocument | undefined)?.content
569
- return readDocContent(content)
570
- } catch {
571
- return null
572
- }
573
- }
574
-
575
- /**
576
- * Get current content from Automerge document
577
- */
578
- private async getCurrentRemoteContent(
579
- url: AutomergeUrl
580
- ): Promise<string | Uint8Array | null> {
581
- try {
582
- const plainUrl = getPlainUrl(url)
583
- const result = await this.findDocument<FileDocument>(plainUrl)
584
-
585
- if (!result) return null
586
-
587
- const content = result.doc.content
588
- return readDocContent(content)
589
- } catch (error) {
590
- out.taskLine(`Failed to get remote content: ${error}`, true)
591
- return null
592
- }
593
- }
594
-
595
- /**
596
- * Find and wait for a document to be available, with retry logic.
597
- * repo.find() rejects with "unavailable" if the server doesn't have the
598
- * document yet, and doc() throws if the handle isn't ready. We retry
599
- * both with backoff since the document may just not have propagated yet.
600
- */
601
- private async findDocument<T>(
602
- url: AutomergeUrl,
603
- options: {maxRetries?: number; retryDelayMs?: number} = {}
604
- ): Promise<{handle: DocHandle<T>; doc: T} | undefined> {
605
- const {maxRetries = 5, retryDelayMs = 500} = options
606
-
607
- for (let attempt = 0; attempt < maxRetries; attempt++) {
608
- try {
609
- const handle = await this.repo.find<T>(url)
610
- const doc = handle.doc()
611
- return {handle, doc}
612
- } catch {
613
- // Document may be unavailable — retry after a delay
614
- if (attempt < maxRetries - 1) {
615
- await new Promise(r => setTimeout(r, retryDelayMs * (attempt + 1)))
616
- }
617
- }
618
- }
619
-
620
- return undefined
621
- }
622
-
623
- /**
624
- * Get current head of Automerge document
625
- */
626
- private async getCurrentRemoteHead(url: AutomergeUrl): Promise<UrlHeads> {
627
- try {
628
- const plainUrl = getPlainUrl(url)
629
- const result = await this.findDocument<FileDocument>(plainUrl, {
630
- maxRetries: 3,
631
- retryDelayMs: 200,
632
- })
633
- if (!result) return [] as unknown as UrlHeads
634
- return result.handle.heads()
635
- } catch {
636
- return [] as unknown as UrlHeads
637
- }
638
- }
639
-
640
- /**
641
- * Determine file type from content
642
- */
643
- private async getFileTypeFromContent(
644
- content: string | Uint8Array | null
645
- ): Promise<FileType> {
646
- if (!content) return FileType.TEXT
647
-
648
- if (content instanceof Uint8Array) {
649
- return FileType.BINARY
650
- } else {
651
- return FileType.TEXT
652
- }
653
- }
654
-
655
- /**
656
- * Classify change type for a path
657
- */
658
- async classifyChange(
659
- relativePath: string,
660
- snapshot: SyncSnapshot
661
- ): Promise<ChangeType> {
662
- const snapshotEntry = snapshot.files.get(relativePath)
663
- const localContent = await this.getLocalContent(relativePath)
664
-
665
- if (!snapshotEntry) {
666
- // New file
667
- return ChangeType.LOCAL_ONLY
668
- }
669
-
670
- const lastKnownContent = await this.getContentAtHead(
671
- snapshotEntry.url,
672
- snapshotEntry.head
673
- )
674
- const currentRemoteContent = await this.getCurrentRemoteContent(
675
- snapshotEntry.url
676
- )
677
-
678
- const localChanged = localContent
679
- ? !isContentEqual(localContent, lastKnownContent)
680
- : true
681
- const remoteChanged = !isContentEqual(
682
- lastKnownContent,
683
- currentRemoteContent
684
- )
685
-
686
- if (!localChanged && !remoteChanged) {
687
- return ChangeType.NO_CHANGE
688
- } else if (localChanged && !remoteChanged) {
689
- return ChangeType.LOCAL_ONLY
690
- } else if (!localChanged && remoteChanged) {
691
- return ChangeType.REMOTE_ONLY
692
- } else {
693
- return ChangeType.BOTH_CHANGED
694
- }
695
- }
696
-
697
- /**
698
- * Find a file's entry in the remote directory hierarchy.
699
- * Returns the entry (with name, type, url) or null if not found.
700
- */
701
- private async findInRemoteDirectory(
702
- rootDirectoryUrl: AutomergeUrl | undefined,
703
- filePath: string
704
- ): Promise<{ name: string; type: string; url: AutomergeUrl } | null> {
705
- if (!rootDirectoryUrl) return null
706
- return findFileInDirectoryHierarchy(
707
- this.repo,
708
- rootDirectoryUrl,
709
- filePath
710
- )
711
- }
712
- }