pushwork 2.0.0-a.sub.0 → 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 -151
  133. package/dist/core/sync-engine.d.ts.map +0 -1
  134. package/dist/core/sync-engine.js +0 -1346
  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 -1758
  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
@@ -0,0 +1,612 @@
1
+ /**
2
+ * White-box tests: import src/ directly and verify the on-disk Automerge doc
3
+ * structure (BranchesDoc wrapping, file-doc indirection, artifact pinning,
4
+ * branch isolation). All tests run fully offline.
5
+ */
6
+
7
+ import * as fs from "fs/promises";
8
+ import * as path from "path";
9
+ import * as tmp from "tmp";
10
+
11
+ async function pathExists(p: string): Promise<boolean> {
12
+ try {
13
+ await fs.access(p);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ import {
20
+ initSubduction,
21
+ parseAutomergeUrl,
22
+ isImmutableString,
23
+ type DocHandle,
24
+ type Repo,
25
+ } from "@automerge/automerge-repo";
26
+ import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs";
27
+ import { Repo as RepoCtor } from "@automerge/automerge-repo";
28
+
29
+ import {
30
+ init,
31
+ save,
32
+ createBranch,
33
+ switchBranch,
34
+ currentBranch,
35
+ listBranches,
36
+ mergeBranch,
37
+ previewMerge,
38
+ cutWorkdir,
39
+ pasteStash,
40
+ showStashes,
41
+ type BranchesDoc,
42
+ isBranchesDoc,
43
+ detectDocType,
44
+ type UnixFileEntry,
45
+ } from "../../src/index.js";
46
+ import { readConfig } from "../../src/config.js";
47
+
48
+ async function openOfflineRepo(storage: string): Promise<Repo> {
49
+ await initSubduction();
50
+ const adapter = new NodeFSStorageAdapter(storage);
51
+ return new RepoCtor({ storage: adapter, network: [] });
52
+ }
53
+
54
+ async function withRepo<T>(
55
+ storage: string,
56
+ fn: (repo: Repo) => Promise<T>,
57
+ ): Promise<T> {
58
+ const repo = await openOfflineRepo(storage);
59
+ try {
60
+ return await fn(repo);
61
+ } finally {
62
+ await repo.shutdown();
63
+ }
64
+ }
65
+
66
+ function readDoc<T>(handle: DocHandle<T>): T {
67
+ const d = handle.doc();
68
+ if (!d) throw new Error(`empty doc at ${handle.url}`);
69
+ return d;
70
+ }
71
+
72
+ describe("doc shape", () => {
73
+ let workRoot: string;
74
+ let cleanup: () => void;
75
+
76
+ beforeEach(() => {
77
+ const t = tmp.dirSync({ unsafeCleanup: true });
78
+ workRoot = t.name;
79
+ cleanup = t.removeCallback;
80
+ });
81
+
82
+ afterEach(() => cleanup());
83
+
84
+ const storageOf = (root: string) => path.join(root, ".pushwork", "storage");
85
+
86
+ it("init wraps the folder doc in a BranchesDoc by default", async () => {
87
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hi\n");
88
+ await init({
89
+ dir: workRoot,
90
+ backend: "subduction",
91
+ shape: "vfs",
92
+ online: false,
93
+ });
94
+ const cfg = await readConfig(workRoot);
95
+ expect(cfg.branches).toBe(true);
96
+
97
+ await withRepo(storageOf(workRoot), async (repo) => {
98
+ const root = await repo.find(cfg.rootUrl);
99
+ expect(detectDocType(root.doc())).toBe("branches");
100
+ const doc = readDoc(root) as BranchesDoc;
101
+ expect(isBranchesDoc(doc)).toBe(true);
102
+ expect(Object.keys(doc.branches)).toEqual(["default"]);
103
+ const folderUrl = doc.branches.default;
104
+ const folder = await repo.find(folderUrl);
105
+ expect(detectDocType(folder.doc())).toBe("directory");
106
+ });
107
+ });
108
+
109
+ it("init --no-branches skips the BranchesDoc wrapper", async () => {
110
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hi\n");
111
+ const rootUrl = await init({
112
+ dir: workRoot,
113
+ backend: "subduction",
114
+ shape: "vfs",
115
+ branches: false,
116
+ online: false,
117
+ });
118
+ const cfg = await readConfig(workRoot);
119
+ expect(cfg.branches).toBe(false);
120
+ expect(cfg.rootUrl).toBe(rootUrl);
121
+ await withRepo(storageOf(workRoot), async (repo) => {
122
+ const root = await repo.find(rootUrl);
123
+ expect(detectDocType(root.doc())).toBe("directory");
124
+ });
125
+ });
126
+
127
+ it("file content is stored in separate UnixFileEntry docs (indirection)", async () => {
128
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hello world\n");
129
+ await init({
130
+ dir: workRoot,
131
+ backend: "subduction",
132
+ shape: "vfs",
133
+ online: false,
134
+ });
135
+ const cfg = await readConfig(workRoot);
136
+ await withRepo(storageOf(workRoot), async (repo) => {
137
+ const root = await repo.find(cfg.rootUrl);
138
+ const doc = readDoc(root) as BranchesDoc;
139
+ const folder = await repo.find(doc.branches.default);
140
+ const folderDoc = readDoc(folder) as Record<string, unknown>;
141
+ const fileUrl = folderDoc["a.txt"];
142
+ expect(typeof fileUrl).toBe("string");
143
+ const file = await repo.find(fileUrl as `automerge:${string}`);
144
+ const fd = readDoc(file) as UnixFileEntry;
145
+ expect(fd["@patchwork"].type).toBe("file");
146
+ expect(fd.content).toBe("hello world\n");
147
+ });
148
+ });
149
+
150
+ it("artifact files store ImmutableString content and pin URL with heads", async () => {
151
+ await fs.mkdir(path.join(workRoot, "dist"));
152
+ await fs.writeFile(path.join(workRoot, "dist", "main.js"), "console.log(1)\n");
153
+ await fs.writeFile(path.join(workRoot, "src.ts"), "export {}\n");
154
+ await init({
155
+ dir: workRoot,
156
+ backend: "subduction",
157
+ shape: "vfs",
158
+ online: false,
159
+ });
160
+ const cfg = await readConfig(workRoot);
161
+ await withRepo(storageOf(workRoot), async (repo) => {
162
+ const root = await repo.find(cfg.rootUrl);
163
+ const doc = readDoc(root) as BranchesDoc;
164
+ const folder = await repo.find(doc.branches.default);
165
+ const folderDoc = readDoc(folder) as Record<string, unknown>;
166
+
167
+ const artifactUrl = folderDoc["dist/main.js"] as string;
168
+ const sourceUrl = folderDoc["src.ts"] as string;
169
+ expect(parseAutomergeUrl(artifactUrl).heads).toBeTruthy();
170
+ expect(parseAutomergeUrl(sourceUrl).heads).toBeFalsy();
171
+
172
+ const artifactDoc = readDoc(await repo.find(artifactUrl)) as UnixFileEntry;
173
+ expect(isImmutableString(artifactDoc.content)).toBe(true);
174
+
175
+ const sourceDoc = readDoc(await repo.find(sourceUrl)) as UnixFileEntry;
176
+ expect(typeof sourceDoc.content).toBe("string");
177
+ expect(isImmutableString(sourceDoc.content)).toBe(false);
178
+ });
179
+ });
180
+
181
+ it("binary files store content as Uint8Array", async () => {
182
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0xff]);
183
+ await fs.writeFile(path.join(workRoot, "img.png"), bytes);
184
+ await init({
185
+ dir: workRoot,
186
+ backend: "subduction",
187
+ shape: "vfs",
188
+ online: false,
189
+ });
190
+ const cfg = await readConfig(workRoot);
191
+ await withRepo(storageOf(workRoot), async (repo) => {
192
+ const root = await repo.find(cfg.rootUrl);
193
+ const doc = readDoc(root) as BranchesDoc;
194
+ const folder = await repo.find(doc.branches.default);
195
+ const folderDoc = readDoc(folder) as Record<string, unknown>;
196
+ const file = await repo.find(folderDoc["img.png"] as `automerge:${string}`);
197
+ const fd = readDoc(file) as UnixFileEntry;
198
+ expect(fd.content instanceof Uint8Array).toBe(true);
199
+ expect(Array.from(fd.content as Uint8Array)).toEqual(Array.from(bytes));
200
+ });
201
+ });
202
+ });
203
+
204
+ describe("file-doc lifecycle", () => {
205
+ let workRoot: string;
206
+ let cleanup: () => void;
207
+
208
+ beforeEach(() => {
209
+ const t = tmp.dirSync({ unsafeCleanup: true });
210
+ workRoot = t.name;
211
+ cleanup = t.removeCallback;
212
+ });
213
+
214
+ afterEach(() => cleanup());
215
+
216
+ const storageOf = (root: string) => path.join(root, ".pushwork", "storage");
217
+
218
+ it("file URLs stay stable within a branch across edits (mutation, not clone)", async () => {
219
+ // pushFiles mutates the existing UnixFileEntry doc in place, which
220
+ // keeps the file URL stable within a branch. (Branch isolation comes
221
+ // from createBranch deep-cloning, not from per-edit cloning.)
222
+ await fs.writeFile(path.join(workRoot, "stable.txt"), "stable\n");
223
+ await fs.writeFile(path.join(workRoot, "edited.txt"), "v1\n");
224
+ await init({
225
+ dir: workRoot,
226
+ backend: "subduction",
227
+ shape: "vfs",
228
+ online: false,
229
+ });
230
+
231
+ async function urlFor(filePath: string): Promise<string> {
232
+ const cfg = await readConfig(workRoot);
233
+ return withRepo(storageOf(workRoot), async (repo) => {
234
+ const root = await repo.find(cfg.rootUrl);
235
+ const doc = readDoc(root) as BranchesDoc;
236
+ const folder = await repo.find(doc.branches.default);
237
+ const folderDoc = readDoc(folder) as Record<string, unknown>;
238
+ return folderDoc[filePath] as string;
239
+ });
240
+ }
241
+
242
+ const stableUrl1 = await urlFor("stable.txt");
243
+ const editedUrl1 = await urlFor("edited.txt");
244
+
245
+ await fs.writeFile(path.join(workRoot, "edited.txt"), "v2\n");
246
+ await save(workRoot);
247
+
248
+ const stableUrl2 = await urlFor("stable.txt");
249
+ const editedUrl2 = await urlFor("edited.txt");
250
+
251
+ expect(stableUrl2).toBe(stableUrl1);
252
+ expect(editedUrl2).toBe(editedUrl1);
253
+ });
254
+
255
+ it("save does not stamp lastSyncAt; sync would (offline test of negative case)", async () => {
256
+ await fs.writeFile(path.join(workRoot, "x.txt"), "x\n");
257
+ await init({
258
+ dir: workRoot,
259
+ backend: "subduction",
260
+ shape: "vfs",
261
+ online: false,
262
+ });
263
+
264
+ async function lastSyncAt(): Promise<number | undefined> {
265
+ const cfg = await readConfig(workRoot);
266
+ return withRepo(storageOf(workRoot), async (repo) => {
267
+ const root = await repo.find(cfg.rootUrl);
268
+ const doc = readDoc(root) as BranchesDoc;
269
+ const folder = await repo.find(doc.branches.default);
270
+ return (readDoc(folder) as { lastSyncAt?: number }).lastSyncAt;
271
+ });
272
+ }
273
+
274
+ // init with online:false skips the lastSyncAt stamp too
275
+ expect(await lastSyncAt()).toBeUndefined();
276
+
277
+ await fs.writeFile(path.join(workRoot, "x.txt"), "y\n");
278
+ await save(workRoot);
279
+
280
+ expect(await lastSyncAt()).toBeUndefined();
281
+ });
282
+ });
283
+
284
+ describe("branch isolation", () => {
285
+ let workRoot: string;
286
+ let cleanup: () => void;
287
+
288
+ beforeEach(() => {
289
+ const t = tmp.dirSync({ unsafeCleanup: true });
290
+ workRoot = t.name;
291
+ cleanup = t.removeCallback;
292
+ });
293
+
294
+ afterEach(() => cleanup());
295
+
296
+ const storageOf = (root: string) => path.join(root, ".pushwork", "storage");
297
+
298
+ it("branch <name> creates a new branch with independent file-doc URLs", async () => {
299
+ // `createBranch` deep-clones every file doc the source folder
300
+ // references so editing on one branch can never alias the other.
301
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hi\n");
302
+ await init({
303
+ dir: workRoot,
304
+ backend: "subduction",
305
+ shape: "vfs",
306
+ online: false,
307
+ });
308
+ await createBranch(workRoot, "feat");
309
+
310
+ const cfg = await readConfig(workRoot);
311
+ await withRepo(storageOf(workRoot), async (repo) => {
312
+ const root = await repo.find(cfg.rootUrl);
313
+ const doc = readDoc(root) as BranchesDoc;
314
+ const defaultUrl = doc.branches.default;
315
+ const featUrl = doc.branches.feat;
316
+ expect(defaultUrl).not.toBe(featUrl);
317
+
318
+ const def = readDoc(await repo.find(defaultUrl)) as Record<
319
+ string,
320
+ unknown
321
+ >;
322
+ const feat = readDoc(await repo.find(featUrl)) as Record<
323
+ string,
324
+ unknown
325
+ >;
326
+ // File URLs differ (clone), but content matches.
327
+ expect(feat["a.txt"]).not.toBe(def["a.txt"]);
328
+ const defFile = readDoc(
329
+ await repo.find(def["a.txt"] as `automerge:${string}`),
330
+ ) as UnixFileEntry;
331
+ const featFile = readDoc(
332
+ await repo.find(feat["a.txt"] as `automerge:${string}`),
333
+ ) as UnixFileEntry;
334
+ expect(featFile.content).toBe(defFile.content);
335
+ });
336
+ });
337
+
338
+ it("editing on a branch does not change the source branch's folder doc", async () => {
339
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hi\n");
340
+ await init({
341
+ dir: workRoot,
342
+ backend: "subduction",
343
+ shape: "vfs",
344
+ online: false,
345
+ });
346
+ await createBranch(workRoot, "feat");
347
+
348
+ const cfg = await readConfig(workRoot);
349
+ const beforeDefault = await withRepo(storageOf(workRoot), async (repo) => {
350
+ const root = await repo.find(cfg.rootUrl);
351
+ const doc = readDoc(root) as BranchesDoc;
352
+ const folder = await repo.find(doc.branches.default);
353
+ return (readDoc(folder) as Record<string, unknown>)["a.txt"];
354
+ });
355
+
356
+ await switchBranch(workRoot, "feat");
357
+ await fs.writeFile(path.join(workRoot, "a.txt"), "edited on feat\n");
358
+ await save(workRoot);
359
+
360
+ const afterDefault = await withRepo(storageOf(workRoot), async (repo) => {
361
+ const root = await repo.find(cfg.rootUrl);
362
+ const doc = readDoc(root) as BranchesDoc;
363
+ const folder = await repo.find(doc.branches.default);
364
+ return (readDoc(folder) as Record<string, unknown>)["a.txt"];
365
+ });
366
+
367
+ expect(afterDefault).toBe(beforeDefault); // default's folder doc unchanged
368
+ });
369
+
370
+ it("merge brings non-conflicting source changes into target", async () => {
371
+ await fs.writeFile(path.join(workRoot, "shared.txt"), "shared\n");
372
+ await fs.writeFile(path.join(workRoot, "target.txt"), "T\n");
373
+ await init({
374
+ dir: workRoot,
375
+ backend: "subduction",
376
+ shape: "vfs",
377
+ online: false,
378
+ });
379
+ await createBranch(workRoot, "feat");
380
+ await switchBranch(workRoot, "feat");
381
+
382
+ // Edit shared.txt and add a new file on feat.
383
+ await fs.writeFile(path.join(workRoot, "shared.txt"), "shared edited on feat\n");
384
+ await fs.writeFile(path.join(workRoot, "feat-only.txt"), "F\n");
385
+ await save(workRoot);
386
+
387
+ // Switch back to default and edit target.txt only.
388
+ await switchBranch(workRoot, "default");
389
+ await fs.writeFile(path.join(workRoot, "target.txt"), "T edited on default\n");
390
+ await save(workRoot);
391
+
392
+ const report = await mergeBranch(workRoot, "feat");
393
+ expect(report.source).toBe("feat");
394
+ expect(report.target).toBe("default");
395
+ expect(report.merged.sort()).toEqual(["shared.txt", "target.txt"]);
396
+ expect(report.added).toEqual(["feat-only.txt"]);
397
+
398
+ expect(await fs.readFile(path.join(workRoot, "shared.txt"), "utf8")).toBe(
399
+ "shared edited on feat\n",
400
+ );
401
+ expect(await fs.readFile(path.join(workRoot, "target.txt"), "utf8")).toBe(
402
+ "T edited on default\n",
403
+ );
404
+ expect(await fs.readFile(path.join(workRoot, "feat-only.txt"), "utf8")).toBe(
405
+ "F\n",
406
+ );
407
+ });
408
+
409
+ it("merge refuses on dirty working tree", async () => {
410
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
411
+ await init({
412
+ dir: workRoot,
413
+ backend: "subduction",
414
+ shape: "vfs",
415
+ online: false,
416
+ });
417
+ await createBranch(workRoot, "feat");
418
+ await fs.writeFile(path.join(workRoot, "dirty.txt"), "uncommitted\n");
419
+ await expect(mergeBranch(workRoot, "feat")).rejects.toThrow(
420
+ /uncommitted changes/,
421
+ );
422
+ });
423
+
424
+ it("merge does character-level CRDT merge on text content", async () => {
425
+ await fs.writeFile(path.join(workRoot, "x.txt"), "line one\nline two\nline three\n");
426
+ await init({
427
+ dir: workRoot,
428
+ backend: "subduction",
429
+ shape: "vfs",
430
+ online: false,
431
+ });
432
+ await createBranch(workRoot, "other");
433
+ await switchBranch(workRoot, "other");
434
+ await fs.writeFile(
435
+ path.join(workRoot, "x.txt"),
436
+ "line one\nline two\nline three EDITED ON OTHER\n",
437
+ );
438
+ await save(workRoot);
439
+ await switchBranch(workRoot, "default");
440
+ await fs.writeFile(
441
+ path.join(workRoot, "x.txt"),
442
+ "line one EDITED ON DEFAULT\nline two\nline three\n",
443
+ );
444
+ await save(workRoot);
445
+ await mergeBranch(workRoot, "other");
446
+
447
+ const merged = await fs.readFile(path.join(workRoot, "x.txt"), "utf8");
448
+ expect(merged).toContain("line one EDITED ON DEFAULT");
449
+ expect(merged).toContain("line three EDITED ON OTHER");
450
+ });
451
+
452
+ it("previewMerge shows entries without mutating", async () => {
453
+ await fs.writeFile(path.join(workRoot, "a.txt"), "v1\n");
454
+ await init({
455
+ dir: workRoot,
456
+ backend: "subduction",
457
+ shape: "vfs",
458
+ online: false,
459
+ });
460
+ await createBranch(workRoot, "feat");
461
+ await switchBranch(workRoot, "feat");
462
+ await fs.writeFile(path.join(workRoot, "a.txt"), "v2\n");
463
+ await fs.writeFile(path.join(workRoot, "new.txt"), "n\n");
464
+ await save(workRoot);
465
+ await switchBranch(workRoot, "default");
466
+
467
+ const preview = await previewMerge(workRoot, "feat");
468
+ const paths = preview.entries.map((e) => e.path).sort();
469
+ expect(paths).toEqual(["a.txt", "new.txt"]);
470
+ expect(preview.entries.find((e) => e.path === "a.txt")?.kind).toBe(
471
+ "merged",
472
+ );
473
+ expect(preview.entries.find((e) => e.path === "new.txt")?.kind).toBe(
474
+ "added",
475
+ );
476
+ // Working tree unchanged on disk:
477
+ expect(await fs.readFile(path.join(workRoot, "a.txt"), "utf8")).toBe("v1\n");
478
+ expect(await pathExists(path.join(workRoot, "new.txt"))).toBe(false);
479
+ });
480
+
481
+ it("merge errors on missing source branch", async () => {
482
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
483
+ await init({
484
+ dir: workRoot,
485
+ backend: "subduction",
486
+ shape: "vfs",
487
+ online: false,
488
+ });
489
+ await expect(mergeBranch(workRoot, "ghost")).rejects.toThrow(/does not exist/);
490
+ });
491
+
492
+ it("cut/paste round-trips modifications, additions, and deletions", async () => {
493
+ await fs.writeFile(path.join(workRoot, "mod.txt"), "v1\n");
494
+ await fs.writeFile(path.join(workRoot, "doomed.txt"), "remove me\n");
495
+ await init({
496
+ dir: workRoot,
497
+ backend: "subduction",
498
+ shape: "vfs",
499
+ online: false,
500
+ });
501
+ await fs.writeFile(path.join(workRoot, "mod.txt"), "v2\n");
502
+ await fs.writeFile(path.join(workRoot, "added.txt"), "new\n");
503
+ await fs.unlink(path.join(workRoot, "doomed.txt"));
504
+
505
+ const cut = await cutWorkdir(workRoot, { name: "wip" });
506
+ expect(cut.entries).toBe(3);
507
+
508
+ // Working tree restored to clean state:
509
+ expect(await fs.readFile(path.join(workRoot, "mod.txt"), "utf8")).toBe(
510
+ "v1\n",
511
+ );
512
+ expect(
513
+ await fs.readFile(path.join(workRoot, "doomed.txt"), "utf8"),
514
+ ).toBe("remove me\n");
515
+ expect(await pathExists(path.join(workRoot, "added.txt"))).toBe(false);
516
+
517
+ const stashes = await showStashes(workRoot);
518
+ expect(stashes.length).toBe(1);
519
+ expect(stashes[0].name).toBe("wip");
520
+ expect(stashes[0].branch).toBe("default");
521
+
522
+ await pasteStash(workRoot);
523
+
524
+ expect(await fs.readFile(path.join(workRoot, "mod.txt"), "utf8")).toBe(
525
+ "v2\n",
526
+ );
527
+ expect(await fs.readFile(path.join(workRoot, "added.txt"), "utf8")).toBe(
528
+ "new\n",
529
+ );
530
+ expect(await pathExists(path.join(workRoot, "doomed.txt"))).toBe(false);
531
+
532
+ expect((await showStashes(workRoot)).length).toBe(0);
533
+ });
534
+
535
+ it("cut refuses on a clean working tree", async () => {
536
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
537
+ await init({
538
+ dir: workRoot,
539
+ backend: "subduction",
540
+ shape: "vfs",
541
+ online: false,
542
+ });
543
+ await expect(cutWorkdir(workRoot)).rejects.toThrow(/working tree clean/);
544
+ });
545
+
546
+ it("paste refuses on a dirty working tree", async () => {
547
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
548
+ await init({
549
+ dir: workRoot,
550
+ backend: "subduction",
551
+ shape: "vfs",
552
+ online: false,
553
+ });
554
+ await fs.writeFile(path.join(workRoot, "a.txt"), "edited\n");
555
+ await cutWorkdir(workRoot);
556
+ // dirty the working tree again
557
+ await fs.writeFile(path.join(workRoot, "b.txt"), "extra\n");
558
+ await expect(pasteStash(workRoot)).rejects.toThrow(/uncommitted/);
559
+ });
560
+
561
+ it("paste with no stashes errors", async () => {
562
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
563
+ await init({
564
+ dir: workRoot,
565
+ backend: "subduction",
566
+ shape: "vfs",
567
+ online: false,
568
+ });
569
+ await expect(pasteStash(workRoot)).rejects.toThrow(/no stashes/);
570
+ });
571
+
572
+ it("paste with id selects a specific stash", async () => {
573
+ await fs.writeFile(path.join(workRoot, "a.txt"), "a\n");
574
+ await init({
575
+ dir: workRoot,
576
+ backend: "subduction",
577
+ shape: "vfs",
578
+ online: false,
579
+ });
580
+ await fs.writeFile(path.join(workRoot, "first.txt"), "1\n");
581
+ const c1 = await cutWorkdir(workRoot, { name: "first" });
582
+ await fs.writeFile(path.join(workRoot, "second.txt"), "2\n");
583
+ const c2 = await cutWorkdir(workRoot, { name: "second" });
584
+
585
+ const out = await pasteStash(workRoot, String(c1.id));
586
+ expect(out.id).toBe(c1.id);
587
+ expect(await pathExists(path.join(workRoot, "first.txt"))).toBe(true);
588
+ // Second stash is still there
589
+ const stashes = await showStashes(workRoot);
590
+ expect(stashes.length).toBe(1);
591
+ expect(stashes[0].id).toBe(c2.id);
592
+ });
593
+
594
+ it("listBranches reports current and all names", async () => {
595
+ await fs.writeFile(path.join(workRoot, "a.txt"), "hi\n");
596
+ await init({
597
+ dir: workRoot,
598
+ backend: "subduction",
599
+ shape: "vfs",
600
+ online: false,
601
+ });
602
+ await createBranch(workRoot, "feat");
603
+ await createBranch(workRoot, "bugfix");
604
+ const out = await listBranches(workRoot);
605
+ expect(out.current).toBe("default");
606
+ expect(out.names.sort()).toEqual(["bugfix", "default", "feat"]);
607
+
608
+ expect(await currentBranch(workRoot)).toBe("default");
609
+ await switchBranch(workRoot, "feat");
610
+ expect(await currentBranch(workRoot)).toBe("feat");
611
+ });
612
+ });
package/tsconfig.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2020",
4
- "module": "nodenext",
5
- "moduleResolution": "nodenext",
4
+ "module": "commonjs",
6
5
  "lib": ["ES2020"],
7
6
  "outDir": "./dist",
8
7
  "rootDir": "./src",
@@ -14,7 +13,7 @@
14
13
  "declarationMap": true,
15
14
  "sourceMap": true,
16
15
  "resolveJsonModule": true,
17
- "types": ["node", "jest"],
16
+ "types": ["node"],
18
17
  "noUnusedLocals": true,
19
18
  "noUnusedParameters": true
20
19
  },
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["src/**/*.{test,spec}.ts", "test/**/*.{test,spec}.ts"],
8
+ setupFiles: ["./test/setup.ts"],
9
+ // Many integration tests spawn the CLI as a subprocess; allow time
10
+ // for build server / sync server roundtrips.
11
+ testTimeout: 60000,
12
+ hookTimeout: 60000,
13
+ },
14
+ });