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
package/src/cli.ts CHANGED
@@ -1,442 +1,292 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { Command, Option } from "@commander-js/extra-typings";
4
- import chalk from "chalk";
2
+ import "./log.js"; // sets up DEBUG=true → DEBUG=* before anything else
3
+ import { Command } from "@commander-js/extra-typings";
4
+ import * as path from "path";
5
5
  import {
6
- init,
7
- clone,
8
- sync,
9
- root,
10
- diff,
11
- status,
12
- log,
13
- checkout,
14
- commit,
15
- url,
16
- rm,
17
- ls,
18
- config,
19
- watch,
20
- read,
21
- } from "./commands.js";
6
+ clone,
7
+ createBranch,
8
+ currentBranch,
9
+ cutWorkdir,
10
+ diff,
11
+ init,
12
+ listBranches,
13
+ mergeBranch,
14
+ pasteStash,
15
+ previewMerge,
16
+ save,
17
+ showStashes,
18
+ status,
19
+ switchBranch,
20
+ sync,
21
+ url,
22
+ } from "./pushwork.js";
23
+ import { log } from "./log.js";
24
+
25
+ const dlog = log("cli");
26
+
27
+ const collect = (value: string, prev: string[] | undefined) =>
28
+ (prev ?? []).concat(value);
22
29
 
23
- import { createRequire } from "module";
24
- const require = createRequire(import.meta.url);
25
- const version = require("../package.json").version;
26
30
  const program = new Command()
27
- .name("pushwork")
28
- .description("Bidirectional directory synchronization using Automerge CRDTs")
29
- .version(version, "-V, --version", "output the version number");
31
+ .name("pushwork")
32
+ .description("Bidirectional directory synchronization using Automerge CRDTs");
30
33
 
31
- // Init command
32
34
  program
33
- .command("init")
34
- .summary("Initialize sync in a directory")
35
- .argument(
36
- "[path]",
37
- "Directory path to initialize (default: current directory)",
38
- "."
39
- )
40
- .option(
41
- "--sync-server <url>",
42
- "Custom sync server URL"
43
- )
44
- .action(async (path, opts) => {
45
- await init(path, { syncServer: opts.syncServer });
46
- });
47
-
48
- // Track command (set root directory URL without full initialization)
49
- const trackAction = async (url: string, path: string, opts: { force: boolean }) => {
50
- await root(url, path, { force: opts.force });
51
- };
35
+ .command("init")
36
+ .description("Initialize pushwork in a directory")
37
+ .argument("[dir]", "Directory to initialize", ".")
38
+ .option("--sub", "Use subduction backend")
39
+ .option(
40
+ "--shape <shape>",
41
+ "Document shape: vfs, patchwork-folder, or path to a custom shape module",
42
+ "vfs",
43
+ )
44
+ .option(
45
+ "--artifact-dir <dir>",
46
+ "Directory whose contents are stored as ImmutableString and pinned with heads in the root doc. Repeatable.",
47
+ collect,
48
+ undefined as string[] | undefined,
49
+ )
50
+ .option("--no-branches", "Skip wrapping the root doc in a branches doc")
51
+ .action(async (dir, opts) => {
52
+ dlog("init dir=%s opts=%o", dir, opts);
53
+ const u = await init({
54
+ dir: path.resolve(dir),
55
+ backend: opts.sub ? "subduction" : "legacy",
56
+ shape: opts.shape,
57
+ artifactDirectories: opts.artifactDir,
58
+ branches: opts.branches,
59
+ });
60
+ process.stderr.write(`initialized ${u}\n`);
61
+ });
52
62
 
53
63
  program
54
- .command("track")
55
- .summary("Set root directory URL without full initialization")
56
- .argument(
57
- "<url>",
58
- "AutomergeUrl of root directory (format: automerge:XXXXX)"
59
- )
60
- .argument(
61
- "[path]",
62
- "Directory path (default: current directory)",
63
- "."
64
- )
65
- .option("-f, --force", "Overwrite existing pushwork setup", false)
66
- .action(async (url, path, opts) => {
67
- await trackAction(url, path, opts);
68
- });
64
+ .command("clone")
65
+ .description("Clone an automerge URL into a directory")
66
+ .argument("<url>", "automerge: URL")
67
+ .argument("[dir]", "Target directory", ".")
68
+ .option("--sub", "Use subduction backend")
69
+ .option(
70
+ "--shape <shape>",
71
+ "Document shape: vfs, patchwork-folder, or path to a custom shape module",
72
+ "vfs",
73
+ )
74
+ .option(
75
+ "--artifact-dir <dir>",
76
+ "Directory whose contents are stored as ImmutableString and pinned with heads in the root doc. Repeatable.",
77
+ collect,
78
+ undefined as string[] | undefined,
79
+ )
80
+ .option("--branch <name>", "Branch to track when cloning a branches doc")
81
+ .action(async (u, dir, opts) => {
82
+ dlog("clone url=%s dir=%s opts=%o", u, dir, opts);
83
+ await clone({
84
+ url: u,
85
+ dir: path.resolve(dir),
86
+ backend: opts.sub ? "subduction" : "legacy",
87
+ shape: opts.shape,
88
+ artifactDirectories: opts.artifactDir,
89
+ branch: opts.branch,
90
+ });
91
+ process.stderr.write(`cloned into ${path.resolve(dir)}\n`);
92
+ });
69
93
 
70
- // Hidden alias for backwards compatibility
71
94
  program
72
- .command("root", { hidden: true })
73
- .argument("<url>")
74
- .argument("[path]", "", ".")
75
- .option("-f, --force", "", false)
76
- .action(async (url: string, path: string, opts: { force: boolean }) => {
77
- await trackAction(url, path, opts);
78
- });
95
+ .command("url")
96
+ .description("Print the automerge URL of this pushwork repo")
97
+ .action(async () => {
98
+ dlog("url cwd=%s", process.cwd());
99
+ const u = await url(process.cwd());
100
+ process.stdout.write(u + "\n");
101
+ });
79
102
 
80
- // Clone command
81
103
  program
82
- .command("clone")
83
- .summary("Clone an existing synced directory")
84
- .argument(
85
- "<url>",
86
- "AutomergeUrl of root directory to clone (format: automerge:XXXXX)"
87
- )
88
- .argument("<path>", "Target directory path")
89
- .option("-f, --force", "Overwrite existing directory", false)
90
- .option(
91
- "--sync-server <url>",
92
- "Custom sync server URL"
93
- )
94
- .option("-v, --verbose", "Verbose output", false)
95
- .action(async (url, path, opts) => {
96
- await clone(url, path, {
97
- force: opts.force,
98
- verbose: opts.verbose,
99
- syncServer: opts.syncServer,
100
- });
101
- });
104
+ .command("sync")
105
+ .description("Sync local changes with peers")
106
+ .action(async () => {
107
+ dlog("sync cwd=%s", process.cwd());
108
+ await sync(process.cwd());
109
+ process.stderr.write("synced\n");
110
+ });
102
111
 
103
- // Commit command
104
112
  program
105
- .command("commit")
106
- .summary("Save local changes to Automerge documents")
107
- .argument(
108
- "[path]",
109
- "Directory path to commit (default: current directory)",
110
- "."
111
- )
112
- .action(async (path, _opts) => {
113
- await commit(path);
114
- });
113
+ .command("save")
114
+ .alias("commit")
115
+ .description("Commit local changes to the current branch without contacting the sync server")
116
+ .action(async () => {
117
+ dlog("save cwd=%s", process.cwd());
118
+ await save(process.cwd());
119
+ process.stderr.write("saved\n");
120
+ });
115
121
 
116
- // Sync command
117
122
  program
118
- .command("sync")
119
- .summary("Run full bidirectional synchronization")
120
- .argument(
121
- "[path]",
122
- "Directory path to sync (default: current directory)",
123
- "."
124
- )
125
- .option(
126
- "--dry-run",
127
- "Show what would be done without applying changes",
128
- false
129
- )
130
- .option(
131
- "--gentle",
132
- "Use config files and only sync changed files (instead of default full resync)",
133
- false
134
- )
135
- .option(
136
- "--nuclear",
137
- "Recreate all Automerge documents from scratch",
138
- false
139
- )
140
- .addOption(new Option("-f, --force", "Accepted for backwards compatibility").default(false).hideHelp())
141
- .option("-v, --verbose", "Verbose output", false)
142
- .action(async (path, opts) => {
143
- await sync(path, {
144
- dryRun: opts.dryRun,
145
- force: opts.force,
146
- gentle: opts.gentle,
147
- nuclear: opts.nuclear,
148
- verbose: opts.verbose,
149
- });
150
- });
123
+ .command("status")
124
+ .description("Show current branch and changes against it")
125
+ .action(async () => {
126
+ const { branch, diff: d } = await status(process.cwd());
127
+ const lines: string[] = [];
128
+ if (branch) lines.push(`On branch ${branch}`);
129
+ else lines.push("(no branches)");
130
+ const total = d.added.length + d.modified.length + d.deleted.length;
131
+ if (total === 0) {
132
+ lines.push("nothing to save, working tree clean");
133
+ } else {
134
+ lines.push("Changes:");
135
+ for (const p of d.modified) lines.push(` modified: ${p}`);
136
+ for (const p of d.added) lines.push(` added: ${p}`);
137
+ for (const p of d.deleted) lines.push(` deleted: ${p}`);
138
+ }
139
+ process.stdout.write(lines.join("\n") + "\n");
140
+ });
151
141
 
152
- // Diff command
153
142
  program
154
- .command("diff")
155
- .summary("Show changes in working directory")
156
- .argument(
157
- "[path]",
158
- "Limit diff to specific path (default: current directory)",
159
- "."
160
- )
161
- .option("--name-only", "Show only changed file names", false)
162
- .action(async (path, opts) => {
163
- await diff(path, {
164
- nameOnly: opts.nameOnly,
165
- });
166
- });
143
+ .command("diff")
144
+ .description("Show textual diffs of local changes against the current branch")
145
+ .argument("[path]", "Limit to a specific path")
146
+ .action(async (limitPath) => {
147
+ const entries = await diff(process.cwd(), limitPath);
148
+ if (entries.length === 0) {
149
+ process.stdout.write("(no changes)\n");
150
+ return;
151
+ }
152
+ const { createPatch } = await import("diff");
153
+ const td = new TextDecoder("utf-8", { fatal: false });
154
+ for (const e of entries) {
155
+ const before = e.before ? td.decode(e.before) : "";
156
+ const after = e.after ? td.decode(e.after) : "";
157
+ const header =
158
+ e.kind === "added" ? `+++ ${e.path}` :
159
+ e.kind === "deleted" ? `--- ${e.path}` :
160
+ `*** ${e.path}`;
161
+ process.stdout.write(header + "\n");
162
+ process.stdout.write(createPatch(e.path, before, after, "", "") + "\n");
163
+ }
164
+ });
167
165
 
168
- // Status command
169
166
  program
170
- .command("status")
171
- .summary("Show sync status summary")
172
- .argument("[path]", "Directory path (default: current directory)", ".")
173
- .option(
174
- "-v, --verbose",
175
- "Show detailed status including document info and all tracked files",
176
- false
177
- )
178
- .action(async (path, opts) => {
179
- await status(path, {
180
- verbose: opts.verbose,
181
- });
182
- });
167
+ .command("branch")
168
+ .description("With no arg: print the current branch. With <name>: create a new branch from the current one (offline).")
169
+ .argument("[name]", "Name of the new branch")
170
+ .action(async (name) => {
171
+ if (!name) {
172
+ const cur = await currentBranch(process.cwd());
173
+ process.stdout.write((cur ?? "(none)") + "\n");
174
+ return;
175
+ }
176
+ const newUrl = await createBranch(process.cwd(), name);
177
+ process.stderr.write(`created branch ${name} → ${newUrl}\n`);
178
+ });
183
179
 
184
- // Log command
185
180
  program
186
- .command("log")
187
- .summary("Show sync history (experimental)")
188
- .argument(
189
- "[path]",
190
- "Show history for specific file or directory (default: current directory)",
191
- "."
192
- )
193
- .option("--oneline", "Compact one-line per sync format", false)
194
- .option("--since <date>", "Show syncs since date")
195
- .option("--limit <n>", "Limit number of syncs shown", "10")
196
- .action(async (path, opts) => {
197
- await log(path, {
198
- oneline: opts.oneline,
199
- since: opts.since,
200
- limit: parseInt(opts.limit),
201
- });
202
- });
181
+ .command("switch")
182
+ .description("Switch to a branch (offline). With no name: list branches.")
183
+ .argument("[name]", "Name of the branch to switch to")
184
+ .action(async (name) => {
185
+ if (!name) {
186
+ const { current, names } = await listBranches(process.cwd());
187
+ for (const n of names) {
188
+ process.stdout.write(`${n === current ? "* " : " "}${n}\n`);
189
+ }
190
+ return;
191
+ }
192
+ await switchBranch(process.cwd(), name);
193
+ process.stderr.write(`switched to ${name}\n`);
194
+ });
203
195
 
204
- // Checkout command
205
196
  program
206
- .command("checkout")
207
- .summary("Restore to previous sync (experimental)")
208
- .argument("<sync-id>", "Sync ID to restore to")
209
- .argument(
210
- "[path]",
211
- "Specific path to restore (default: current directory)",
212
- "."
213
- )
214
- .option(
215
- "-f, --force",
216
- "Force checkout even if there are uncommitted changes",
217
- false
218
- )
219
- .action(async (syncId, path, opts) => {
220
- await checkout(syncId, path, {
221
- force: opts.force,
222
- });
223
- });
197
+ .command("merge")
198
+ .description("Apply changes from <source> branch onto the current branch (offline)")
199
+ .argument("<source>", "Branch to merge into the current one")
200
+ .option("--dry", "Preview the merge without applying")
201
+ .action(async (source, opts) => {
202
+ if (opts.dry) {
203
+ const preview = await previewMerge(process.cwd(), source);
204
+ const lines: string[] = [];
205
+ lines.push(`Merging ${preview.source} into ${preview.target} (preview)`);
206
+ if (preview.entries.length === 0) {
207
+ lines.push("(no changes)");
208
+ process.stdout.write(lines.join("\n") + "\n");
209
+ return;
210
+ }
211
+ const { createPatch } = await import("diff");
212
+ const td = new TextDecoder("utf-8", { fatal: false });
213
+ for (const e of preview.entries) {
214
+ const before = e.before ? td.decode(e.before) : "";
215
+ const after = td.decode(e.after);
216
+ const tag = e.kind === "added" ? "added" : "merged";
217
+ lines.push(` ${tag}: ${e.path}`);
218
+ lines.push(createPatch(e.path, before, after, "", ""));
219
+ }
220
+ process.stdout.write(lines.join("\n") + "\n");
221
+ return;
222
+ }
223
+ const report = await mergeBranch(process.cwd(), source);
224
+ const lines: string[] = [];
225
+ lines.push(`Merging ${report.source} into ${report.target}`);
226
+ if (report.merged.length === 0 && report.added.length === 0) {
227
+ lines.push("(no changes)");
228
+ } else {
229
+ for (const p of report.merged) lines.push(` merged: ${p}`);
230
+ for (const p of report.added) lines.push(` added: ${p}`);
231
+ }
232
+ process.stdout.write(lines.join("\n") + "\n");
233
+ });
224
234
 
225
- // URL command
226
235
  program
227
- .command("url")
228
- .summary("Show the Automerge root URL")
229
- .argument("[path]", "Directory path (default: current directory)", ".")
230
- .action(async (path) => {
231
- await url(path);
232
- });
236
+ .command("cut")
237
+ .description("Stash working-tree changes against the current branch and reset the tree to clean (offline)")
238
+ .argument("[name]", "Optional name for the stash entry")
239
+ .action(async (name) => {
240
+ const result = await cutWorkdir(process.cwd(), { name });
241
+ process.stderr.write(`cut #${result.id}: ${result.entries} entr${result.entries === 1 ? "y" : "ies"}\n`);
242
+ });
233
243
 
234
- // Remove command
235
244
  program
236
- .command("rm")
237
- .summary("Remove local pushwork data")
238
- .argument("[path]", "Directory path (default: current directory)", ".")
239
- .action(async (path) => {
240
- await rm(path);
241
- });
245
+ .command("paste")
246
+ .description("Re-apply a stashed set of changes; default is the most recent (offline)")
247
+ .argument("[id-or-name]", "Stash id or name")
248
+ .action(async (selector) => {
249
+ const result = await pasteStash(process.cwd(), selector);
250
+ process.stderr.write(
251
+ `pasted #${result.id}${result.name ? ` (${result.name})` : ""}: ${result.entries} entr${result.entries === 1 ? "y" : "ies"}\n`,
252
+ );
253
+ });
242
254
 
243
- // List command
244
255
  program
245
- .command("ls")
246
- .summary("List tracked files")
247
- .argument("[path]", "Directory path (default: current directory)", ".")
248
- .option("-v, --verbose", "Show with Automerge URLs", false)
249
- .action(async (path, opts) => {
250
- await ls(path, {
251
- verbose: opts.verbose,
252
- });
253
- });
256
+ .command("cuts")
257
+ .description("List stashed change sets (newest first)")
258
+ .action(async () => {
259
+ const stashes = await showStashes(process.cwd());
260
+ if (stashes.length === 0) {
261
+ process.stdout.write("(no stashes)\n");
262
+ return;
263
+ }
264
+ for (const s of stashes) {
265
+ const ts = new Date(s.createdAt).toISOString();
266
+ const label = s.name ? `"${s.name}"` : "";
267
+ const branch = s.branch ? ` on ${s.branch}` : "";
268
+ process.stdout.write(
269
+ `#${s.id}${label ? " " + label : ""}${branch} ${s.entries.length} entr${s.entries.length === 1 ? "y" : "ies"} ${ts}\n`,
270
+ );
271
+ }
272
+ });
254
273
 
255
- // Config command
256
274
  program
257
- .command("config")
258
- .summary("View or edit configuration")
259
- .argument("[path]", "Directory path (default: current directory)", ".")
260
- .option("--list", "Show full configuration", false)
261
- .option(
262
- "--get <key>",
263
- "Get specific config value (dot notation, e.g., sync.move_detection_threshold)"
264
- )
265
- .action(async (path, opts) => {
266
- await config(path, {
267
- list: opts.list,
268
- get: opts.get,
269
- });
270
- });
271
-
272
- // Watch command
273
- program
274
- .command("watch")
275
- .summary("Watch directory for changes, build, and sync")
276
- .argument(
277
- "[path]",
278
- "Directory path to sync (default: current directory)",
279
- "."
280
- )
281
- .option(
282
- "--script <command>",
283
- "Build script to run before syncing",
284
- "pnpm build"
285
- )
286
- .option(
287
- "--dir <dir>",
288
- "Directory to watch for changes (relative to working directory)",
289
- "src"
290
- )
291
- .option("-v, --verbose", "Show build script output", false)
292
- .action(async (path, opts) => {
293
- await watch(path, {
294
- script: opts.script,
295
- watchDir: opts.dir,
296
- verbose: opts.verbose,
297
- });
298
- });
299
-
300
- // Read command
301
- program
302
- .command("read")
303
- .summary("Read a file document by its Automerge URL")
304
- .argument(
305
- "<url>",
306
- "AutomergeUrl of file document (format: automerge:XXXXX)"
307
- )
308
- .option("-r, --remote", "Read from sync server instead of local storage", false)
309
- .action(async (url, opts) => {
310
- await read(url, {
311
- remote: opts.remote,
312
- });
313
- });
314
-
315
- // Completion command (hidden from help)
316
- program.command("completion", { hidden: true }).action(() => {
317
- // Generate completion dynamically from registered commands
318
- const commands = program.commands
319
- .filter((cmd) => cmd.name() !== "completion") // Exclude self
320
- .map((cmd) => {
321
- const name = cmd.name();
322
- const desc = (cmd.summary() || cmd.description() || "").replace(
323
- /'/g,
324
- "\\'"
325
- );
326
- return `'${name}:${desc}'`;
327
- })
328
- .join(" ");
329
-
330
- // Generate option completions for each command
331
- const commandCases = program.commands
332
- .filter((cmd) => cmd.name() !== "completion")
333
- .map((cmd) => {
334
- const options = cmd.options
335
- .filter((opt) => opt.flags !== "-h, --help") // Exclude help
336
- .map((opt) => {
337
- // Parse flags like "-v, --verbose" or "--dry-run"
338
- const flags = opt.flags.split(",").map((f) => f.trim());
339
- const desc = (opt.description || "")
340
- .replace(/'/g, "\\'")
341
- .replace(/\n/g, " ");
342
-
343
- // For options with arguments like "--sync-server <url>"
344
- // Extract just the flag part
345
- const cleanFlags = flags.map((f) => f.split(/\s+/)[0]);
275
+ .command("branches")
276
+ .description("List branches")
277
+ .action(async () => {
278
+ const { current, names } = await listBranches(process.cwd());
279
+ for (const n of names) {
280
+ process.stdout.write(`${n === current ? "* " : " "}${n}\n`);
281
+ }
282
+ });
346
283
 
347
- if (cleanFlags.length > 1) {
348
- // Multiple flags (short and long): '(-v --verbose)'{-v,--verbose}'[description]'
349
- const short = cleanFlags[0];
350
- const long = cleanFlags[1];
351
- return `'(${short} ${long})'{${short},${long}}'[${desc}]'`;
352
- } else {
353
- // Single flag: '--flag[description]'
354
- return `'${cleanFlags[0]}[${desc}]'`;
355
- }
356
- })
357
- .join(" \\\n ");
358
-
359
- return options
360
- ? ` ${cmd.name()})
361
- _arguments \\
362
- ${options}
363
- ;;`
364
- : "";
365
- })
366
- .filter(Boolean)
367
- .join("\n");
368
-
369
- const completionScript = `
370
- # pushwork completion for zsh
371
- _pushwork() {
372
- local -a commands
373
- commands=(${commands})
374
-
375
- _arguments -C \\
376
- '1: :->command' \\
377
- '*::arg:->args'
378
-
379
- case $state in
380
- command)
381
- _describe 'command' commands
382
- ;;
383
- args)
384
- case $words[1] in
385
- ${commandCases}
386
- esac
387
- ;;
388
- esac
389
- }
390
-
391
- compdef _pushwork pushwork
392
- `.trim();
393
-
394
- console.log(completionScript);
395
- });
396
-
397
- process.on("unhandledRejection", (error) => {
398
- console.log(chalk.bgRed.white(" ERROR "));
399
- if (error instanceof Error && error.stack) {
400
- console.log(chalk.red(error.stack));
401
- } else {
402
- console.error(chalk.red(error));
403
- }
404
- process.exit(1);
405
- });
406
-
407
- // Configure help colors using Commander v13's built-in color support
408
284
  program
409
- .configureHelp({
410
- styleTitle: (str) => chalk.bold(str),
411
- styleCommandText: (str) => chalk.white(str),
412
- styleCommandDescription: (str) => chalk.dim(str),
413
- styleOptionText: (str) => chalk.green(str),
414
- styleArgumentText: (str) => chalk.cyan(str),
415
- subcommandTerm: (cmd) => {
416
- const opts = cmd.options
417
- .filter((opt) => opt.flags !== "-h, --help")
418
- .map((opt) => opt.short || opt.long)
419
- .join(", ");
420
-
421
- const name = chalk.white(cmd.name());
422
- const args = cmd.registeredArguments
423
- .map((arg) =>
424
- arg.required
425
- ? chalk.cyan(`<${arg.name()}>`)
426
- : chalk.dim(`[${arg.name()}]`)
427
- )
428
- .join(" ");
429
-
430
- return [name, args, opts && chalk.dim(`[${opts}]`)]
431
- .filter(Boolean)
432
- .join(" ");
433
- },
434
- })
435
- .addHelpText(
436
- "after",
437
- chalk.dim(
438
- '\nEnable tab completion by adding this to your ~/.zshrc:\neval "$(pushwork completion)"'
439
- )
440
- );
441
-
442
- program.parseAsync();
285
+ .parseAsync(process.argv)
286
+ .then(() => process.exit(0))
287
+ .catch((err) => {
288
+ process.stderr.write(
289
+ `pushwork: ${err instanceof Error ? err.message : String(err)}\n`,
290
+ );
291
+ process.exit(1);
292
+ });