@toolkit-cli/toolkode 1.3.7

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 (676) hide show
  1. package/AGENTS.md +69 -0
  2. package/BUN_SHELL_MIGRATION_PLAN.md +136 -0
  3. package/Dockerfile +18 -0
  4. package/README.md +15 -0
  5. package/bin/opencode +179 -0
  6. package/bin/toolkode +17 -0
  7. package/bin/toolkode.cjs +190 -0
  8. package/bunfig.toml +7 -0
  9. package/drizzle.config.ts +10 -0
  10. package/git +0 -0
  11. package/migration/20260127222353_familiar_lady_ursula/migration.sql +90 -0
  12. package/migration/20260127222353_familiar_lady_ursula/snapshot.json +796 -0
  13. package/migration/20260211171708_add_project_commands/migration.sql +1 -0
  14. package/migration/20260211171708_add_project_commands/snapshot.json +806 -0
  15. package/migration/20260213144116_wakeful_the_professor/migration.sql +11 -0
  16. package/migration/20260213144116_wakeful_the_professor/snapshot.json +897 -0
  17. package/migration/20260225215848_workspace/migration.sql +7 -0
  18. package/migration/20260225215848_workspace/snapshot.json +959 -0
  19. package/migration/20260227213759_add_session_workspace_id/migration.sql +2 -0
  20. package/migration/20260227213759_add_session_workspace_id/snapshot.json +983 -0
  21. package/migration/20260228203230_blue_harpoon/migration.sql +17 -0
  22. package/migration/20260228203230_blue_harpoon/snapshot.json +1102 -0
  23. package/migration/20260303231226_add_workspace_fields/migration.sql +5 -0
  24. package/migration/20260303231226_add_workspace_fields/snapshot.json +1013 -0
  25. package/migration/20260309230000_move_org_to_state/migration.sql +3 -0
  26. package/migration/20260309230000_move_org_to_state/snapshot.json +1156 -0
  27. package/migration/20260312043431_session_message_cursor/migration.sql +4 -0
  28. package/migration/20260312043431_session_message_cursor/snapshot.json +1168 -0
  29. package/migration/20260323234822_events/migration.sql +13 -0
  30. package/migration/20260323234822_events/snapshot.json +1271 -0
  31. package/package.json +160 -0
  32. package/parsers-config.ts +290 -0
  33. package/script/build-node.ts +54 -0
  34. package/script/build.ts +276 -0
  35. package/script/check-migrations.ts +16 -0
  36. package/script/postinstall.mjs +131 -0
  37. package/script/publish.ts +181 -0
  38. package/script/schema.ts +63 -0
  39. package/script/seed-e2e.ts +60 -0
  40. package/script/upgrade-opentui.ts +64 -0
  41. package/specs/effect-migration.md +293 -0
  42. package/specs/tui-plugins.md +389 -0
  43. package/src/account/account.sql.ts +39 -0
  44. package/src/account/index.ts +397 -0
  45. package/src/account/repo.ts +163 -0
  46. package/src/account/schema.ts +91 -0
  47. package/src/acp/README.md +174 -0
  48. package/src/acp/agent.ts +1743 -0
  49. package/src/acp/session.ts +116 -0
  50. package/src/acp/types.ts +24 -0
  51. package/src/agent/agent.ts +418 -0
  52. package/src/agent/generate.txt +75 -0
  53. package/src/agent/prompt/compaction.txt +14 -0
  54. package/src/agent/prompt/explore.txt +18 -0
  55. package/src/agent/prompt/summary.txt +11 -0
  56. package/src/agent/prompt/title.txt +44 -0
  57. package/src/auth/index.ts +115 -0
  58. package/src/bun/index.ts +128 -0
  59. package/src/bun/registry.ts +50 -0
  60. package/src/bus/bus-event.ts +40 -0
  61. package/src/bus/global.ts +10 -0
  62. package/src/bus/index.ts +184 -0
  63. package/src/channel/index.ts +231 -0
  64. package/src/cli/bootstrap.ts +17 -0
  65. package/src/cli/cmd/account.ts +257 -0
  66. package/src/cli/cmd/acp.ts +70 -0
  67. package/src/cli/cmd/agent.ts +245 -0
  68. package/src/cli/cmd/cmd.ts +7 -0
  69. package/src/cli/cmd/db.ts +119 -0
  70. package/src/cli/cmd/debug/agent.ts +167 -0
  71. package/src/cli/cmd/debug/config.ts +16 -0
  72. package/src/cli/cmd/debug/file.ts +97 -0
  73. package/src/cli/cmd/debug/index.ts +48 -0
  74. package/src/cli/cmd/debug/lsp.ts +53 -0
  75. package/src/cli/cmd/debug/ripgrep.ts +87 -0
  76. package/src/cli/cmd/debug/scrap.ts +16 -0
  77. package/src/cli/cmd/debug/skill.ts +16 -0
  78. package/src/cli/cmd/debug/snapshot.ts +52 -0
  79. package/src/cli/cmd/export.ts +89 -0
  80. package/src/cli/cmd/generate.ts +38 -0
  81. package/src/cli/cmd/github.ts +1646 -0
  82. package/src/cli/cmd/import.ts +207 -0
  83. package/src/cli/cmd/mcp.ts +754 -0
  84. package/src/cli/cmd/models.ts +78 -0
  85. package/src/cli/cmd/plug.ts +231 -0
  86. package/src/cli/cmd/pr.ts +127 -0
  87. package/src/cli/cmd/providers.ts +482 -0
  88. package/src/cli/cmd/run.ts +738 -0
  89. package/src/cli/cmd/serve.ts +42 -0
  90. package/src/cli/cmd/session.ts +159 -0
  91. package/src/cli/cmd/stats.ts +410 -0
  92. package/src/cli/cmd/tui/app.tsx +1255 -0
  93. package/src/cli/cmd/tui/attach.ts +88 -0
  94. package/src/cli/cmd/tui/component/border.tsx +21 -0
  95. package/src/cli/cmd/tui/component/dialog-agent.tsx +31 -0
  96. package/src/cli/cmd/tui/component/dialog-command.tsx +171 -0
  97. package/src/cli/cmd/tui/component/dialog-mcp.tsx +86 -0
  98. package/src/cli/cmd/tui/component/dialog-model.tsx +264 -0
  99. package/src/cli/cmd/tui/component/dialog-provider.tsx +334 -0
  100. package/src/cli/cmd/tui/component/dialog-session-list.tsx +108 -0
  101. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +31 -0
  102. package/src/cli/cmd/tui/component/dialog-skill.tsx +36 -0
  103. package/src/cli/cmd/tui/component/dialog-stash.tsx +87 -0
  104. package/src/cli/cmd/tui/component/dialog-status.tsx +168 -0
  105. package/src/cli/cmd/tui/component/dialog-tag.tsx +44 -0
  106. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +50 -0
  107. package/src/cli/cmd/tui/component/dialog-variant.tsx +29 -0
  108. package/src/cli/cmd/tui/component/dialog-workspace-list.tsx +320 -0
  109. package/src/cli/cmd/tui/component/error-component.tsx +91 -0
  110. package/src/cli/cmd/tui/component/logo.tsx +86 -0
  111. package/src/cli/cmd/tui/component/plugin-route-missing.tsx +14 -0
  112. package/src/cli/cmd/tui/component/prompt/autocomplete.tsx +667 -0
  113. package/src/cli/cmd/tui/component/prompt/frecency.tsx +90 -0
  114. package/src/cli/cmd/tui/component/prompt/history.tsx +108 -0
  115. package/src/cli/cmd/tui/component/prompt/index.tsx +1353 -0
  116. package/src/cli/cmd/tui/component/prompt/part.ts +16 -0
  117. package/src/cli/cmd/tui/component/prompt/stash.tsx +101 -0
  118. package/src/cli/cmd/tui/component/spinner.tsx +24 -0
  119. package/src/cli/cmd/tui/component/startup-loading.tsx +63 -0
  120. package/src/cli/cmd/tui/component/textarea-keybindings.ts +73 -0
  121. package/src/cli/cmd/tui/component/todo-item.tsx +32 -0
  122. package/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +151 -0
  123. package/src/cli/cmd/tui/context/args.tsx +15 -0
  124. package/src/cli/cmd/tui/context/directory.ts +13 -0
  125. package/src/cli/cmd/tui/context/exit.tsx +60 -0
  126. package/src/cli/cmd/tui/context/helper.tsx +25 -0
  127. package/src/cli/cmd/tui/context/keybind.tsx +105 -0
  128. package/src/cli/cmd/tui/context/kv.tsx +52 -0
  129. package/src/cli/cmd/tui/context/local.tsx +406 -0
  130. package/src/cli/cmd/tui/context/plugin-keybinds.ts +41 -0
  131. package/src/cli/cmd/tui/context/prompt.tsx +18 -0
  132. package/src/cli/cmd/tui/context/route.tsx +52 -0
  133. package/src/cli/cmd/tui/context/sdk.tsx +128 -0
  134. package/src/cli/cmd/tui/context/sync.tsx +504 -0
  135. package/src/cli/cmd/tui/context/theme/amber.json +245 -0
  136. package/src/cli/cmd/tui/context/theme/amiga.json +245 -0
  137. package/src/cli/cmd/tui/context/theme/atari.json +245 -0
  138. package/src/cli/cmd/tui/context/theme/aura.json +69 -0
  139. package/src/cli/cmd/tui/context/theme/ayu.json +80 -0
  140. package/src/cli/cmd/tui/context/theme/borland.json +245 -0
  141. package/src/cli/cmd/tui/context/theme/carbonfox.json +248 -0
  142. package/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +233 -0
  143. package/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +233 -0
  144. package/src/cli/cmd/tui/context/theme/catppuccin.json +112 -0
  145. package/src/cli/cmd/tui/context/theme/cobalt2.json +228 -0
  146. package/src/cli/cmd/tui/context/theme/commodore.json +245 -0
  147. package/src/cli/cmd/tui/context/theme/cursor.json +249 -0
  148. package/src/cli/cmd/tui/context/theme/dos-edit.json +245 -0
  149. package/src/cli/cmd/tui/context/theme/dracula.json +219 -0
  150. package/src/cli/cmd/tui/context/theme/everforest.json +241 -0
  151. package/src/cli/cmd/tui/context/theme/flexoki.json +237 -0
  152. package/src/cli/cmd/tui/context/theme/github.json +233 -0
  153. package/src/cli/cmd/tui/context/theme/gnu.json +245 -0
  154. package/src/cli/cmd/tui/context/theme/gruvbox.json +242 -0
  155. package/src/cli/cmd/tui/context/theme/hacker.json +245 -0
  156. package/src/cli/cmd/tui/context/theme/irix.json +245 -0
  157. package/src/cli/cmd/tui/context/theme/kanagawa.json +77 -0
  158. package/src/cli/cmd/tui/context/theme/lucent-orng.json +237 -0
  159. package/src/cli/cmd/tui/context/theme/mac84.json +245 -0
  160. package/src/cli/cmd/tui/context/theme/material.json +235 -0
  161. package/src/cli/cmd/tui/context/theme/matrix.json +77 -0
  162. package/src/cli/cmd/tui/context/theme/mercury.json +252 -0
  163. package/src/cli/cmd/tui/context/theme/monokai.json +221 -0
  164. package/src/cli/cmd/tui/context/theme/nightowl.json +221 -0
  165. package/src/cli/cmd/tui/context/theme/nord.json +223 -0
  166. package/src/cli/cmd/tui/context/theme/norton.json +245 -0
  167. package/src/cli/cmd/tui/context/theme/one-dark.json +84 -0
  168. package/src/cli/cmd/tui/context/theme/opencode.json +245 -0
  169. package/src/cli/cmd/tui/context/theme/orng.json +249 -0
  170. package/src/cli/cmd/tui/context/theme/osaka-jade.json +93 -0
  171. package/src/cli/cmd/tui/context/theme/palenight.json +222 -0
  172. package/src/cli/cmd/tui/context/theme/pine.json +245 -0
  173. package/src/cli/cmd/tui/context/theme/retrowave.json +245 -0
  174. package/src/cli/cmd/tui/context/theme/rosepine.json +234 -0
  175. package/src/cli/cmd/tui/context/theme/solarized.json +223 -0
  176. package/src/cli/cmd/tui/context/theme/synthwave84.json +226 -0
  177. package/src/cli/cmd/tui/context/theme/tokyonight.json +243 -0
  178. package/src/cli/cmd/tui/context/theme/toolkode.json +245 -0
  179. package/src/cli/cmd/tui/context/theme/tron.json +245 -0
  180. package/src/cli/cmd/tui/context/theme/ubuntu.json +245 -0
  181. package/src/cli/cmd/tui/context/theme/vercel.json +245 -0
  182. package/src/cli/cmd/tui/context/theme/vesper.json +218 -0
  183. package/src/cli/cmd/tui/context/theme/vt100.json +245 -0
  184. package/src/cli/cmd/tui/context/theme/xcode.json +245 -0
  185. package/src/cli/cmd/tui/context/theme/zenburn.json +223 -0
  186. package/src/cli/cmd/tui/context/theme.tsx +1288 -0
  187. package/src/cli/cmd/tui/context/tui-config.tsx +9 -0
  188. package/src/cli/cmd/tui/event.ts +49 -0
  189. package/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +152 -0
  190. package/src/cli/cmd/tui/feature-plugins/home/tips.tsx +50 -0
  191. package/src/cli/cmd/tui/feature-plugins/sidebar/agents-panel.tsx +95 -0
  192. package/src/cli/cmd/tui/feature-plugins/sidebar/btw-panel.tsx +105 -0
  193. package/src/cli/cmd/tui/feature-plugins/sidebar/commands-panel.tsx +40 -0
  194. package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +63 -0
  195. package/src/cli/cmd/tui/feature-plugins/sidebar/files.tsx +62 -0
  196. package/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx +93 -0
  197. package/src/cli/cmd/tui/feature-plugins/sidebar/git-panel.tsx +36 -0
  198. package/src/cli/cmd/tui/feature-plugins/sidebar/loop-panel.tsx +124 -0
  199. package/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +66 -0
  200. package/src/cli/cmd/tui/feature-plugins/sidebar/mcp.tsx +96 -0
  201. package/src/cli/cmd/tui/feature-plugins/sidebar/session-panel.tsx +48 -0
  202. package/src/cli/cmd/tui/feature-plugins/sidebar/todo.tsx +48 -0
  203. package/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +270 -0
  204. package/src/cli/cmd/tui/plugin/api.tsx +420 -0
  205. package/src/cli/cmd/tui/plugin/index.ts +3 -0
  206. package/src/cli/cmd/tui/plugin/internal.ts +37 -0
  207. package/src/cli/cmd/tui/plugin/runtime.ts +967 -0
  208. package/src/cli/cmd/tui/plugin/slots.tsx +61 -0
  209. package/src/cli/cmd/tui/routes/home.tsx +173 -0
  210. package/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +65 -0
  211. package/src/cli/cmd/tui/routes/session/dialog-message.tsx +110 -0
  212. package/src/cli/cmd/tui/routes/session/dialog-subagent.tsx +26 -0
  213. package/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +47 -0
  214. package/src/cli/cmd/tui/routes/session/footer.tsx +91 -0
  215. package/src/cli/cmd/tui/routes/session/index.tsx +2229 -0
  216. package/src/cli/cmd/tui/routes/session/permission.tsx +685 -0
  217. package/src/cli/cmd/tui/routes/session/question.tsx +467 -0
  218. package/src/cli/cmd/tui/routes/session/sidebar.tsx +72 -0
  219. package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +131 -0
  220. package/src/cli/cmd/tui/thread.ts +232 -0
  221. package/src/cli/cmd/tui/ui/dialog-alert.tsx +59 -0
  222. package/src/cli/cmd/tui/ui/dialog-confirm.tsx +89 -0
  223. package/src/cli/cmd/tui/ui/dialog-export-options.tsx +208 -0
  224. package/src/cli/cmd/tui/ui/dialog-help.tsx +40 -0
  225. package/src/cli/cmd/tui/ui/dialog-prompt.tsx +106 -0
  226. package/src/cli/cmd/tui/ui/dialog-select.tsx +402 -0
  227. package/src/cli/cmd/tui/ui/dialog.tsx +192 -0
  228. package/src/cli/cmd/tui/ui/link.tsx +28 -0
  229. package/src/cli/cmd/tui/ui/spinner.ts +368 -0
  230. package/src/cli/cmd/tui/ui/toast.tsx +100 -0
  231. package/src/cli/cmd/tui/util/clipboard.ts +192 -0
  232. package/src/cli/cmd/tui/util/editor.ts +37 -0
  233. package/src/cli/cmd/tui/util/selection.ts +25 -0
  234. package/src/cli/cmd/tui/util/signal.ts +7 -0
  235. package/src/cli/cmd/tui/util/terminal.ts +114 -0
  236. package/src/cli/cmd/tui/util/transcript.ts +98 -0
  237. package/src/cli/cmd/tui/win32.ts +129 -0
  238. package/src/cli/cmd/tui/worker.ts +204 -0
  239. package/src/cli/cmd/uninstall.ts +353 -0
  240. package/src/cli/cmd/upgrade.ts +73 -0
  241. package/src/cli/cmd/web.ts +81 -0
  242. package/src/cli/effect/prompt.ts +25 -0
  243. package/src/cli/error.ts +46 -0
  244. package/src/cli/logo.ts +7 -0
  245. package/src/cli/network.ts +60 -0
  246. package/src/cli/ui.ts +116 -0
  247. package/src/cli/upgrade.ts +31 -0
  248. package/src/command/index.ts +195 -0
  249. package/src/command/template/initialize.txt +10 -0
  250. package/src/command/template/review.txt +101 -0
  251. package/src/config/config.ts +1693 -0
  252. package/src/config/markdown.ts +99 -0
  253. package/src/config/migrate-tui-config.ts +155 -0
  254. package/src/config/paths.ts +174 -0
  255. package/src/config/tui-schema.ts +36 -0
  256. package/src/config/tui.ts +212 -0
  257. package/src/control-plane/adaptors/index.ts +20 -0
  258. package/src/control-plane/adaptors/worktree.ts +38 -0
  259. package/src/control-plane/schema.ts +17 -0
  260. package/src/control-plane/sse.ts +66 -0
  261. package/src/control-plane/types.ts +21 -0
  262. package/src/control-plane/workspace.sql.ts +17 -0
  263. package/src/control-plane/workspace.ts +154 -0
  264. package/src/cron/index.ts +241 -0
  265. package/src/cron/parse.ts +189 -0
  266. package/src/effect/cross-spawn-spawner.ts +479 -0
  267. package/src/effect/instance-registry.ts +12 -0
  268. package/src/effect/instance-state.ts +47 -0
  269. package/src/effect/run-service.ts +19 -0
  270. package/src/env/index.ts +28 -0
  271. package/src/file/ignore.ts +82 -0
  272. package/src/file/index.ts +693 -0
  273. package/src/file/protected.ts +59 -0
  274. package/src/file/ripgrep.ts +376 -0
  275. package/src/file/time.ts +128 -0
  276. package/src/file/watcher.ts +171 -0
  277. package/src/filesystem/index.ts +226 -0
  278. package/src/flag/flag.ts +157 -0
  279. package/src/format/formatter.ts +396 -0
  280. package/src/format/index.ts +199 -0
  281. package/src/global/index.ts +54 -0
  282. package/src/hooks/index.ts +302 -0
  283. package/src/id/id.ts +85 -0
  284. package/src/ide/index.ts +74 -0
  285. package/src/index.ts +243 -0
  286. package/src/installation/index.ts +363 -0
  287. package/src/lsp/client.ts +252 -0
  288. package/src/lsp/index.ts +558 -0
  289. package/src/lsp/language.ts +120 -0
  290. package/src/lsp/launch.ts +21 -0
  291. package/src/lsp/server.ts +2093 -0
  292. package/src/mcp/auth.ts +181 -0
  293. package/src/mcp/index.ts +926 -0
  294. package/src/mcp/oauth-callback.ts +215 -0
  295. package/src/mcp/oauth-provider.ts +185 -0
  296. package/src/node.ts +1 -0
  297. package/src/patch/index.ts +680 -0
  298. package/src/permission/arity.ts +163 -0
  299. package/src/permission/evaluate.ts +15 -0
  300. package/src/permission/index.ts +322 -0
  301. package/src/permission/schema.ts +17 -0
  302. package/src/plugin/codex.ts +628 -0
  303. package/src/plugin/copilot.ts +343 -0
  304. package/src/plugin/index.ts +331 -0
  305. package/src/plugin/install.ts +384 -0
  306. package/src/plugin/meta.ts +165 -0
  307. package/src/plugin/shared.ts +172 -0
  308. package/src/project/bootstrap.ts +31 -0
  309. package/src/project/instance.ts +167 -0
  310. package/src/project/project.sql.ts +16 -0
  311. package/src/project/project.ts +519 -0
  312. package/src/project/schema.ts +16 -0
  313. package/src/project/state.ts +70 -0
  314. package/src/project/vcs.ts +124 -0
  315. package/src/provider/auth.ts +252 -0
  316. package/src/provider/error.ts +197 -0
  317. package/src/provider/models.ts +138 -0
  318. package/src/provider/provider.ts +1593 -0
  319. package/src/provider/schema.ts +39 -0
  320. package/src/provider/sdk/copilot/README.md +5 -0
  321. package/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts +170 -0
  322. package/src/provider/sdk/copilot/chat/get-response-metadata.ts +15 -0
  323. package/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts +19 -0
  324. package/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts +64 -0
  325. package/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts +815 -0
  326. package/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts +28 -0
  327. package/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts +44 -0
  328. package/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts +83 -0
  329. package/src/provider/sdk/copilot/copilot-provider.ts +100 -0
  330. package/src/provider/sdk/copilot/index.ts +2 -0
  331. package/src/provider/sdk/copilot/openai-compatible-error.ts +27 -0
  332. package/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts +335 -0
  333. package/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts +22 -0
  334. package/src/provider/sdk/copilot/responses/openai-config.ts +18 -0
  335. package/src/provider/sdk/copilot/responses/openai-error.ts +22 -0
  336. package/src/provider/sdk/copilot/responses/openai-responses-api-types.ts +214 -0
  337. package/src/provider/sdk/copilot/responses/openai-responses-language-model.ts +1769 -0
  338. package/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts +173 -0
  339. package/src/provider/sdk/copilot/responses/openai-responses-settings.ts +1 -0
  340. package/src/provider/sdk/copilot/responses/tool/code-interpreter.ts +87 -0
  341. package/src/provider/sdk/copilot/responses/tool/file-search.ts +127 -0
  342. package/src/provider/sdk/copilot/responses/tool/image-generation.ts +114 -0
  343. package/src/provider/sdk/copilot/responses/tool/local-shell.ts +64 -0
  344. package/src/provider/sdk/copilot/responses/tool/web-search-preview.ts +103 -0
  345. package/src/provider/sdk/copilot/responses/tool/web-search.ts +102 -0
  346. package/src/provider/toolkit-manifest.ts +110 -0
  347. package/src/provider/transform.ts +1045 -0
  348. package/src/pty/index.ts +397 -0
  349. package/src/pty/schema.ts +17 -0
  350. package/src/question/index.ts +221 -0
  351. package/src/question/schema.ts +17 -0
  352. package/src/server/error.ts +36 -0
  353. package/src/server/event.ts +7 -0
  354. package/src/server/instance.ts +285 -0
  355. package/src/server/mdns.ts +60 -0
  356. package/src/server/middleware.ts +29 -0
  357. package/src/server/projectors.ts +28 -0
  358. package/src/server/router.ts +99 -0
  359. package/src/server/routes/config.ts +92 -0
  360. package/src/server/routes/event.ts +83 -0
  361. package/src/server/routes/experimental.ts +271 -0
  362. package/src/server/routes/file.ts +197 -0
  363. package/src/server/routes/global.ts +339 -0
  364. package/src/server/routes/mcp.ts +225 -0
  365. package/src/server/routes/permission.ts +69 -0
  366. package/src/server/routes/project.ts +118 -0
  367. package/src/server/routes/provider.ts +171 -0
  368. package/src/server/routes/pty.ts +211 -0
  369. package/src/server/routes/question.ts +99 -0
  370. package/src/server/routes/session.ts +1031 -0
  371. package/src/server/routes/tui.ts +379 -0
  372. package/src/server/routes/workspace.ts +94 -0
  373. package/src/server/server.ts +312 -0
  374. package/src/session/compaction.ts +424 -0
  375. package/src/session/index.ts +882 -0
  376. package/src/session/instruction.ts +321 -0
  377. package/src/session/llm.ts +341 -0
  378. package/src/session/message-v2.ts +1030 -0
  379. package/src/session/message.ts +191 -0
  380. package/src/session/overflow.ts +22 -0
  381. package/src/session/processor.ts +554 -0
  382. package/src/session/projectors.ts +135 -0
  383. package/src/session/prompt/anthropic.txt +105 -0
  384. package/src/session/prompt/beast.txt +147 -0
  385. package/src/session/prompt/build-switch.txt +5 -0
  386. package/src/session/prompt/codex.txt +79 -0
  387. package/src/session/prompt/copilot-gpt-5.txt +143 -0
  388. package/src/session/prompt/default.txt +108 -0
  389. package/src/session/prompt/gemini.txt +155 -0
  390. package/src/session/prompt/gpt.txt +107 -0
  391. package/src/session/prompt/max-steps.txt +16 -0
  392. package/src/session/prompt/plan-reminder-anthropic.txt +67 -0
  393. package/src/session/prompt/plan.txt +26 -0
  394. package/src/session/prompt/trinity.txt +97 -0
  395. package/src/session/prompt.ts +2058 -0
  396. package/src/session/retry.ts +106 -0
  397. package/src/session/revert.ts +138 -0
  398. package/src/session/schema.ts +38 -0
  399. package/src/session/session.sql.ts +103 -0
  400. package/src/session/status.ts +102 -0
  401. package/src/session/summary.ts +170 -0
  402. package/src/session/system.ts +74 -0
  403. package/src/session/todo.ts +57 -0
  404. package/src/share/share-next.ts +288 -0
  405. package/src/share/share.sql.ts +13 -0
  406. package/src/shell/shell.ts +73 -0
  407. package/src/skill/discovery.ts +116 -0
  408. package/src/skill/index.ts +284 -0
  409. package/src/skills-marketplace/index.ts +305 -0
  410. package/src/snapshot/index.ts +489 -0
  411. package/src/sql.d.ts +4 -0
  412. package/src/storage/db.bun.ts +8 -0
  413. package/src/storage/db.node.ts +8 -0
  414. package/src/storage/db.ts +177 -0
  415. package/src/storage/json-migration.ts +425 -0
  416. package/src/storage/schema.sql.ts +10 -0
  417. package/src/storage/schema.ts +5 -0
  418. package/src/storage/storage.ts +217 -0
  419. package/src/sync/README.md +179 -0
  420. package/src/sync/event.sql.ts +16 -0
  421. package/src/sync/index.ts +263 -0
  422. package/src/sync/schema.ts +14 -0
  423. package/src/team/index.ts +428 -0
  424. package/src/tool/apply_patch.ts +281 -0
  425. package/src/tool/apply_patch.txt +33 -0
  426. package/src/tool/bash.ts +271 -0
  427. package/src/tool/bash.txt +115 -0
  428. package/src/tool/batch.ts +183 -0
  429. package/src/tool/batch.txt +24 -0
  430. package/src/tool/codesearch.ts +132 -0
  431. package/src/tool/codesearch.txt +12 -0
  432. package/src/tool/cron-create.ts +54 -0
  433. package/src/tool/cron-create.txt +16 -0
  434. package/src/tool/cron-delete.ts +29 -0
  435. package/src/tool/cron-delete.txt +1 -0
  436. package/src/tool/cron-list.ts +41 -0
  437. package/src/tool/cron-list.txt +1 -0
  438. package/src/tool/edit.ts +667 -0
  439. package/src/tool/edit.txt +10 -0
  440. package/src/tool/external-directory.ts +32 -0
  441. package/src/tool/glob.ts +78 -0
  442. package/src/tool/glob.txt +6 -0
  443. package/src/tool/grep.ts +156 -0
  444. package/src/tool/grep.txt +8 -0
  445. package/src/tool/invalid.ts +17 -0
  446. package/src/tool/ls.ts +121 -0
  447. package/src/tool/ls.txt +1 -0
  448. package/src/tool/lsp.ts +97 -0
  449. package/src/tool/lsp.txt +19 -0
  450. package/src/tool/multiedit.ts +46 -0
  451. package/src/tool/multiedit.txt +41 -0
  452. package/src/tool/plan-enter.txt +14 -0
  453. package/src/tool/plan-exit.txt +13 -0
  454. package/src/tool/plan.ts +131 -0
  455. package/src/tool/question.ts +33 -0
  456. package/src/tool/question.txt +10 -0
  457. package/src/tool/read.ts +293 -0
  458. package/src/tool/read.txt +14 -0
  459. package/src/tool/registry.ts +232 -0
  460. package/src/tool/schema.ts +17 -0
  461. package/src/tool/send-message.ts +59 -0
  462. package/src/tool/send-message.txt +7 -0
  463. package/src/tool/skill.ts +105 -0
  464. package/src/tool/task.ts +230 -0
  465. package/src/tool/task.txt +62 -0
  466. package/src/tool/team.ts +235 -0
  467. package/src/tool/team.txt +22 -0
  468. package/src/tool/todo.ts +31 -0
  469. package/src/tool/todowrite.txt +167 -0
  470. package/src/tool/tool.ts +90 -0
  471. package/src/tool/truncate.ts +144 -0
  472. package/src/tool/truncation-dir.ts +4 -0
  473. package/src/tool/webfetch.ts +206 -0
  474. package/src/tool/webfetch.txt +13 -0
  475. package/src/tool/websearch.ts +150 -0
  476. package/src/tool/websearch.txt +14 -0
  477. package/src/tool/write.ts +84 -0
  478. package/src/tool/write.txt +8 -0
  479. package/src/util/abort.ts +35 -0
  480. package/src/util/archive.ts +17 -0
  481. package/src/util/color.ts +19 -0
  482. package/src/util/context.ts +25 -0
  483. package/src/util/data-url.ts +9 -0
  484. package/src/util/defer.ts +12 -0
  485. package/src/util/effect-http-client.ts +11 -0
  486. package/src/util/effect-zod.ts +98 -0
  487. package/src/util/error.ts +77 -0
  488. package/src/util/filesystem.ts +203 -0
  489. package/src/util/flock.ts +333 -0
  490. package/src/util/fn.ts +21 -0
  491. package/src/util/format.ts +20 -0
  492. package/src/util/git.ts +35 -0
  493. package/src/util/glob.ts +34 -0
  494. package/src/util/hash.ts +7 -0
  495. package/src/util/iife.ts +3 -0
  496. package/src/util/keybind.ts +103 -0
  497. package/src/util/lazy.ts +23 -0
  498. package/src/util/locale.ts +81 -0
  499. package/src/util/lock.ts +98 -0
  500. package/src/util/log.ts +182 -0
  501. package/src/util/network.ts +9 -0
  502. package/src/util/process.ts +172 -0
  503. package/src/util/queue.ts +32 -0
  504. package/src/util/record.ts +3 -0
  505. package/src/util/rpc.ts +66 -0
  506. package/src/util/schema.ts +53 -0
  507. package/src/util/scrap.ts +10 -0
  508. package/src/util/signal.ts +12 -0
  509. package/src/util/timeout.ts +14 -0
  510. package/src/util/token.ts +7 -0
  511. package/src/util/update-schema.ts +13 -0
  512. package/src/util/which.ts +14 -0
  513. package/src/util/wildcard.ts +59 -0
  514. package/src/worktree/index.ts +638 -0
  515. package/sst-env.d.ts +10 -0
  516. package/test/AGENTS.md +81 -0
  517. package/test/account/repo.test.ts +326 -0
  518. package/test/account/service.test.ts +282 -0
  519. package/test/acp/agent-interface.test.ts +51 -0
  520. package/test/acp/event-subscription.test.ts +685 -0
  521. package/test/agent/agent.test.ts +717 -0
  522. package/test/auth/auth.test.ts +58 -0
  523. package/test/bun.test.ts +53 -0
  524. package/test/bus/bus-effect.test.ts +164 -0
  525. package/test/bus/bus-integration.test.ts +87 -0
  526. package/test/bus/bus.test.ts +219 -0
  527. package/test/cli/account.test.ts +26 -0
  528. package/test/cli/cmd/tui/prompt-part.test.ts +47 -0
  529. package/test/cli/github-action.test.ts +198 -0
  530. package/test/cli/github-remote.test.ts +80 -0
  531. package/test/cli/import.test.ts +54 -0
  532. package/test/cli/plugin-auth-picker.test.ts +120 -0
  533. package/test/cli/tui/keybind-plugin.test.ts +90 -0
  534. package/test/cli/tui/plugin-add.test.ts +61 -0
  535. package/test/cli/tui/plugin-install.test.ts +95 -0
  536. package/test/cli/tui/plugin-lifecycle.test.ts +225 -0
  537. package/test/cli/tui/plugin-loader-entrypoint.test.ts +189 -0
  538. package/test/cli/tui/plugin-loader-pure.test.ts +71 -0
  539. package/test/cli/tui/plugin-loader.test.ts +563 -0
  540. package/test/cli/tui/plugin-toggle.test.ts +157 -0
  541. package/test/cli/tui/theme-store.test.ts +51 -0
  542. package/test/cli/tui/thread.test.ts +128 -0
  543. package/test/cli/tui/transcript.test.ts +322 -0
  544. package/test/config/agent-color.test.ts +71 -0
  545. package/test/config/config.test.ts +2187 -0
  546. package/test/config/fixtures/empty-frontmatter.md +4 -0
  547. package/test/config/fixtures/frontmatter.md +28 -0
  548. package/test/config/fixtures/markdown-header.md +11 -0
  549. package/test/config/fixtures/no-frontmatter.md +1 -0
  550. package/test/config/fixtures/weird-model-id.md +13 -0
  551. package/test/config/markdown.test.ts +228 -0
  552. package/test/config/tui.test.ts +667 -0
  553. package/test/control-plane/sse.test.ts +56 -0
  554. package/test/effect/cross-spawn-spawner.test.ts +402 -0
  555. package/test/effect/instance-state.test.ts +384 -0
  556. package/test/effect/run-service.test.ts +46 -0
  557. package/test/file/fsmonitor.test.ts +62 -0
  558. package/test/file/ignore.test.ts +10 -0
  559. package/test/file/index.test.ts +946 -0
  560. package/test/file/path-traversal.test.ts +198 -0
  561. package/test/file/ripgrep.test.ts +54 -0
  562. package/test/file/time.test.ts +354 -0
  563. package/test/file/watcher.test.ts +247 -0
  564. package/test/filesystem/filesystem.test.ts +319 -0
  565. package/test/fixture/db.ts +11 -0
  566. package/test/fixture/fixture.test.ts +26 -0
  567. package/test/fixture/fixture.ts +141 -0
  568. package/test/fixture/flock-worker.ts +72 -0
  569. package/test/fixture/lsp/fake-lsp-server.js +77 -0
  570. package/test/fixture/plug-worker.ts +93 -0
  571. package/test/fixture/plugin-meta-worker.ts +26 -0
  572. package/test/fixture/skills/agents-sdk/SKILL.md +152 -0
  573. package/test/fixture/skills/agents-sdk/references/callable.md +92 -0
  574. package/test/fixture/skills/cloudflare/SKILL.md +211 -0
  575. package/test/fixture/skills/index.json +6 -0
  576. package/test/fixture/tui-plugin.ts +335 -0
  577. package/test/fixture/tui-runtime.ts +34 -0
  578. package/test/format/format.test.ts +179 -0
  579. package/test/ide/ide.test.ts +82 -0
  580. package/test/installation/installation.test.ts +151 -0
  581. package/test/keybind.test.ts +421 -0
  582. package/test/lib/effect.ts +37 -0
  583. package/test/lib/filesystem.ts +10 -0
  584. package/test/lsp/client.test.ts +95 -0
  585. package/test/lsp/index.test.ts +55 -0
  586. package/test/lsp/launch.test.ts +22 -0
  587. package/test/lsp/lifecycle.test.ts +147 -0
  588. package/test/mcp/headers.test.ts +153 -0
  589. package/test/mcp/lifecycle.test.ts +750 -0
  590. package/test/mcp/oauth-auto-connect.test.ts +199 -0
  591. package/test/mcp/oauth-browser.test.ts +249 -0
  592. package/test/memory/abort-leak.test.ts +137 -0
  593. package/test/patch/patch.test.ts +348 -0
  594. package/test/permission/arity.test.ts +33 -0
  595. package/test/permission/next.test.ts +1148 -0
  596. package/test/permission-task.test.ts +323 -0
  597. package/test/plugin/auth-override.test.ts +74 -0
  598. package/test/plugin/codex.test.ts +123 -0
  599. package/test/plugin/install-concurrency.test.ts +134 -0
  600. package/test/plugin/install.test.ts +504 -0
  601. package/test/plugin/loader-shared.test.ts +625 -0
  602. package/test/plugin/meta.test.ts +137 -0
  603. package/test/plugin/trigger.test.ts +111 -0
  604. package/test/preload.ts +90 -0
  605. package/test/project/migrate-global.test.ts +140 -0
  606. package/test/project/project.test.ts +459 -0
  607. package/test/project/state.test.ts +115 -0
  608. package/test/project/vcs.test.ts +116 -0
  609. package/test/project/worktree-remove.test.ts +96 -0
  610. package/test/project/worktree.test.ts +173 -0
  611. package/test/provider/amazon-bedrock.test.ts +447 -0
  612. package/test/provider/copilot/convert-to-copilot-messages.test.ts +523 -0
  613. package/test/provider/copilot/copilot-chat-model.test.ts +592 -0
  614. package/test/provider/gitlab-duo.test.ts +412 -0
  615. package/test/provider/provider.test.ts +2284 -0
  616. package/test/provider/transform.test.ts +2758 -0
  617. package/test/pty/pty-output-isolation.test.ts +141 -0
  618. package/test/pty/pty-session.test.ts +92 -0
  619. package/test/question/question.test.ts +453 -0
  620. package/test/server/global-session-list.test.ts +89 -0
  621. package/test/server/project-init-git.test.ts +121 -0
  622. package/test/server/session-list.test.ts +90 -0
  623. package/test/server/session-messages.test.ts +132 -0
  624. package/test/server/session-select.test.ts +78 -0
  625. package/test/session/compaction.test.ts +1094 -0
  626. package/test/session/instruction.test.ts +170 -0
  627. package/test/session/llm.test.ts +882 -0
  628. package/test/session/message-v2.test.ts +957 -0
  629. package/test/session/messages-pagination.test.ts +115 -0
  630. package/test/session/processor-effect.test.ts +838 -0
  631. package/test/session/prompt.test.ts +518 -0
  632. package/test/session/retry.test.ts +232 -0
  633. package/test/session/revert-compact.test.ts +286 -0
  634. package/test/session/session.test.ts +142 -0
  635. package/test/session/structured-output-integration.test.ts +233 -0
  636. package/test/session/structured-output.test.ts +391 -0
  637. package/test/session/system.test.ts +59 -0
  638. package/test/share/share-next.test.ts +76 -0
  639. package/test/skill/discovery.test.ts +116 -0
  640. package/test/skill/skill.test.ts +392 -0
  641. package/test/snapshot/snapshot.test.ts +1235 -0
  642. package/test/storage/db.test.ts +14 -0
  643. package/test/storage/json-migration.test.ts +849 -0
  644. package/test/sync/index.test.ts +191 -0
  645. package/test/tool/__snapshots__/tool.test.ts.snap +9 -0
  646. package/test/tool/apply_patch.test.ts +567 -0
  647. package/test/tool/bash.test.ts +403 -0
  648. package/test/tool/edit.test.ts +681 -0
  649. package/test/tool/external-directory.test.ts +128 -0
  650. package/test/tool/fixtures/large-image.png +0 -0
  651. package/test/tool/fixtures/models-api.json +38413 -0
  652. package/test/tool/grep.test.ts +111 -0
  653. package/test/tool/question.test.ts +108 -0
  654. package/test/tool/read.test.ts +509 -0
  655. package/test/tool/registry.test.ts +126 -0
  656. package/test/tool/skill.test.ts +167 -0
  657. package/test/tool/task.test.ts +49 -0
  658. package/test/tool/truncation.test.ts +161 -0
  659. package/test/tool/webfetch.test.ts +101 -0
  660. package/test/tool/write.test.ts +353 -0
  661. package/test/util/data-url.test.ts +14 -0
  662. package/test/util/effect-zod.test.ts +61 -0
  663. package/test/util/error.test.ts +38 -0
  664. package/test/util/filesystem.test.ts +558 -0
  665. package/test/util/flock.test.ts +383 -0
  666. package/test/util/format.test.ts +59 -0
  667. package/test/util/glob.test.ts +164 -0
  668. package/test/util/iife.test.ts +36 -0
  669. package/test/util/lazy.test.ts +50 -0
  670. package/test/util/lock.test.ts +72 -0
  671. package/test/util/module.test.ts +59 -0
  672. package/test/util/process.test.ts +128 -0
  673. package/test/util/timeout.test.ts +21 -0
  674. package/test/util/which.test.ts +100 -0
  675. package/test/util/wildcard.test.ts +90 -0
  676. package/tsconfig.json +23 -0
@@ -0,0 +1,1235 @@
1
+ import { afterEach, test, expect } from "bun:test"
2
+ import { $ } from "bun"
3
+ import fs from "fs/promises"
4
+ import path from "path"
5
+ import { Snapshot } from "../../src/snapshot"
6
+ import { Instance } from "../../src/project/instance"
7
+ import { Filesystem } from "../../src/util/filesystem"
8
+ import { tmpdir } from "../fixture/fixture"
9
+
10
+ // Git always outputs /-separated paths internally. Snapshot.patch() joins them
11
+ // with path.join (which produces \ on Windows) then normalizes back to /.
12
+ // This helper does the same for expected values so assertions match cross-platform.
13
+ const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
14
+
15
+ afterEach(async () => {
16
+ await Instance.disposeAll()
17
+ })
18
+
19
+ async function bootstrap() {
20
+ return tmpdir({
21
+ git: true,
22
+ init: async (dir) => {
23
+ const unique = Math.random().toString(36).slice(2)
24
+ const aContent = `A${unique}`
25
+ const bContent = `B${unique}`
26
+ await Filesystem.write(`${dir}/a.txt`, aContent)
27
+ await Filesystem.write(`${dir}/b.txt`, bContent)
28
+ await $`git add .`.cwd(dir).quiet()
29
+ await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet()
30
+ return {
31
+ aContent,
32
+ bContent,
33
+ }
34
+ },
35
+ })
36
+ }
37
+
38
+ test("tracks deleted files correctly", async () => {
39
+ await using tmp = await bootstrap()
40
+ await Instance.provide({
41
+ directory: tmp.path,
42
+ fn: async () => {
43
+ const before = await Snapshot.track()
44
+ expect(before).toBeTruthy()
45
+
46
+ await $`rm ${tmp.path}/a.txt`.quiet()
47
+
48
+ expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "a.txt"))
49
+ },
50
+ })
51
+ })
52
+
53
+ test("revert should remove new files", async () => {
54
+ await using tmp = await bootstrap()
55
+ await Instance.provide({
56
+ directory: tmp.path,
57
+ fn: async () => {
58
+ const before = await Snapshot.track()
59
+ expect(before).toBeTruthy()
60
+
61
+ await Filesystem.write(`${tmp.path}/new.txt`, "NEW")
62
+
63
+ await Snapshot.revert([await Snapshot.patch(before!)])
64
+
65
+ expect(
66
+ await fs
67
+ .access(`${tmp.path}/new.txt`)
68
+ .then(() => true)
69
+ .catch(() => false),
70
+ ).toBe(false)
71
+ },
72
+ })
73
+ })
74
+
75
+ test("revert in subdirectory", async () => {
76
+ await using tmp = await bootstrap()
77
+ await Instance.provide({
78
+ directory: tmp.path,
79
+ fn: async () => {
80
+ const before = await Snapshot.track()
81
+ expect(before).toBeTruthy()
82
+
83
+ await $`mkdir -p ${tmp.path}/sub`.quiet()
84
+ await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB")
85
+
86
+ await Snapshot.revert([await Snapshot.patch(before!)])
87
+
88
+ expect(
89
+ await fs
90
+ .access(`${tmp.path}/sub/file.txt`)
91
+ .then(() => true)
92
+ .catch(() => false),
93
+ ).toBe(false)
94
+ // Note: revert currently only removes files, not directories
95
+ // The empty subdirectory will remain
96
+ },
97
+ })
98
+ })
99
+
100
+ test("multiple file operations", async () => {
101
+ await using tmp = await bootstrap()
102
+ await Instance.provide({
103
+ directory: tmp.path,
104
+ fn: async () => {
105
+ const before = await Snapshot.track()
106
+ expect(before).toBeTruthy()
107
+
108
+ await $`rm ${tmp.path}/a.txt`.quiet()
109
+ await Filesystem.write(`${tmp.path}/c.txt`, "C")
110
+ await $`mkdir -p ${tmp.path}/dir`.quiet()
111
+ await Filesystem.write(`${tmp.path}/dir/d.txt`, "D")
112
+ await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED")
113
+
114
+ await Snapshot.revert([await Snapshot.patch(before!)])
115
+
116
+ expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
117
+ expect(
118
+ await fs
119
+ .access(`${tmp.path}/c.txt`)
120
+ .then(() => true)
121
+ .catch(() => false),
122
+ ).toBe(false)
123
+ // Note: revert currently only removes files, not directories
124
+ // The empty directory will remain
125
+ expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
126
+ },
127
+ })
128
+ })
129
+
130
+ test("empty directory handling", async () => {
131
+ await using tmp = await bootstrap()
132
+ await Instance.provide({
133
+ directory: tmp.path,
134
+ fn: async () => {
135
+ const before = await Snapshot.track()
136
+ expect(before).toBeTruthy()
137
+
138
+ await $`mkdir ${tmp.path}/empty`.quiet()
139
+
140
+ expect((await Snapshot.patch(before!)).files.length).toBe(0)
141
+ },
142
+ })
143
+ })
144
+
145
+ test("binary file handling", async () => {
146
+ await using tmp = await bootstrap()
147
+ await Instance.provide({
148
+ directory: tmp.path,
149
+ fn: async () => {
150
+ const before = await Snapshot.track()
151
+ expect(before).toBeTruthy()
152
+
153
+ await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47]))
154
+
155
+ const patch = await Snapshot.patch(before!)
156
+ expect(patch.files).toContain(fwd(tmp.path, "image.png"))
157
+
158
+ await Snapshot.revert([patch])
159
+ expect(
160
+ await fs
161
+ .access(`${tmp.path}/image.png`)
162
+ .then(() => true)
163
+ .catch(() => false),
164
+ ).toBe(false)
165
+ },
166
+ })
167
+ })
168
+
169
+ test("symlink handling", async () => {
170
+ await using tmp = await bootstrap()
171
+ await Instance.provide({
172
+ directory: tmp.path,
173
+ fn: async () => {
174
+ const before = await Snapshot.track()
175
+ expect(before).toBeTruthy()
176
+
177
+ await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")
178
+
179
+ expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "link.txt"))
180
+ },
181
+ })
182
+ })
183
+
184
+ test("file under size limit handling", async () => {
185
+ await using tmp = await bootstrap()
186
+ await Instance.provide({
187
+ directory: tmp.path,
188
+ fn: async () => {
189
+ const before = await Snapshot.track()
190
+ expect(before).toBeTruthy()
191
+
192
+ await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024))
193
+
194
+ expect((await Snapshot.patch(before!)).files).toContain(fwd(tmp.path, "large.txt"))
195
+ },
196
+ })
197
+ })
198
+
199
+ test("large added files are skipped", async () => {
200
+ await using tmp = await bootstrap()
201
+ await Instance.provide({
202
+ directory: tmp.path,
203
+ fn: async () => {
204
+ const before = await Snapshot.track()
205
+ expect(before).toBeTruthy()
206
+
207
+ await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
208
+
209
+ expect((await Snapshot.patch(before!)).files).toEqual([])
210
+ expect(await Snapshot.diff(before!)).toBe("")
211
+ expect(await Snapshot.track()).toBe(before)
212
+ },
213
+ })
214
+ })
215
+
216
+ test("nested directory revert", async () => {
217
+ await using tmp = await bootstrap()
218
+ await Instance.provide({
219
+ directory: tmp.path,
220
+ fn: async () => {
221
+ const before = await Snapshot.track()
222
+ expect(before).toBeTruthy()
223
+
224
+ await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet()
225
+ await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP")
226
+
227
+ await Snapshot.revert([await Snapshot.patch(before!)])
228
+
229
+ expect(
230
+ await fs
231
+ .access(`${tmp.path}/level1/level2/level3/deep.txt`)
232
+ .then(() => true)
233
+ .catch(() => false),
234
+ ).toBe(false)
235
+ },
236
+ })
237
+ })
238
+
239
+ test("special characters in filenames", async () => {
240
+ await using tmp = await bootstrap()
241
+ await Instance.provide({
242
+ directory: tmp.path,
243
+ fn: async () => {
244
+ const before = await Snapshot.track()
245
+ expect(before).toBeTruthy()
246
+
247
+ await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES")
248
+ await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES")
249
+ await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES")
250
+
251
+ const files = (await Snapshot.patch(before!)).files
252
+ expect(files).toContain(fwd(tmp.path, "file with spaces.txt"))
253
+ expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt"))
254
+ expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt"))
255
+ },
256
+ })
257
+ })
258
+
259
+ test("revert with empty patches", async () => {
260
+ await using tmp = await bootstrap()
261
+ await Instance.provide({
262
+ directory: tmp.path,
263
+ fn: async () => {
264
+ // Should not crash with empty patches
265
+ expect(Snapshot.revert([])).resolves.toBeUndefined()
266
+
267
+ // Should not crash with patches that have empty file lists
268
+ expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
269
+ },
270
+ })
271
+ })
272
+
273
+ test("patch with invalid hash", async () => {
274
+ await using tmp = await bootstrap()
275
+ await Instance.provide({
276
+ directory: tmp.path,
277
+ fn: async () => {
278
+ const before = await Snapshot.track()
279
+ expect(before).toBeTruthy()
280
+
281
+ // Create a change
282
+ await Filesystem.write(`${tmp.path}/test.txt`, "TEST")
283
+
284
+ // Try to patch with invalid hash - should handle gracefully
285
+ const patch = await Snapshot.patch("invalid-hash-12345")
286
+ expect(patch.files).toEqual([])
287
+ expect(patch.hash).toBe("invalid-hash-12345")
288
+ },
289
+ })
290
+ })
291
+
292
+ test("revert non-existent file", async () => {
293
+ await using tmp = await bootstrap()
294
+ await Instance.provide({
295
+ directory: tmp.path,
296
+ fn: async () => {
297
+ const before = await Snapshot.track()
298
+ expect(before).toBeTruthy()
299
+
300
+ // Try to revert a file that doesn't exist in the snapshot
301
+ // This should not crash
302
+ expect(
303
+ Snapshot.revert([
304
+ {
305
+ hash: before!,
306
+ files: [`${tmp.path}/nonexistent.txt`],
307
+ },
308
+ ]),
309
+ ).resolves.toBeUndefined()
310
+ },
311
+ })
312
+ })
313
+
314
+ test("unicode filenames", async () => {
315
+ await using tmp = await bootstrap()
316
+ await Instance.provide({
317
+ directory: tmp.path,
318
+ fn: async () => {
319
+ const before = await Snapshot.track()
320
+ expect(before).toBeTruthy()
321
+
322
+ const unicodeFiles = [
323
+ { path: fwd(tmp.path, "文件.txt"), content: "chinese content" },
324
+ { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" },
325
+ { path: fwd(tmp.path, "café.txt"), content: "accented content" },
326
+ { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" },
327
+ ]
328
+
329
+ for (const file of unicodeFiles) {
330
+ await Filesystem.write(file.path, file.content)
331
+ }
332
+
333
+ const patch = await Snapshot.patch(before!)
334
+ expect(patch.files.length).toBe(4)
335
+
336
+ for (const file of unicodeFiles) {
337
+ expect(patch.files).toContain(file.path)
338
+ }
339
+
340
+ await Snapshot.revert([patch])
341
+
342
+ for (const file of unicodeFiles) {
343
+ expect(
344
+ await fs
345
+ .access(file.path)
346
+ .then(() => true)
347
+ .catch(() => false),
348
+ ).toBe(false)
349
+ }
350
+ },
351
+ })
352
+ })
353
+
354
+ test.skip("unicode filenames modification and restore", async () => {
355
+ await using tmp = await bootstrap()
356
+ await Instance.provide({
357
+ directory: tmp.path,
358
+ fn: async () => {
359
+ const chineseFile = fwd(tmp.path, "文件.txt")
360
+ const cyrillicFile = fwd(tmp.path, "файл.txt")
361
+
362
+ await Filesystem.write(chineseFile, "original chinese")
363
+ await Filesystem.write(cyrillicFile, "original cyrillic")
364
+
365
+ const before = await Snapshot.track()
366
+ expect(before).toBeTruthy()
367
+
368
+ await Filesystem.write(chineseFile, "modified chinese")
369
+ await Filesystem.write(cyrillicFile, "modified cyrillic")
370
+
371
+ const patch = await Snapshot.patch(before!)
372
+ expect(patch.files).toContain(chineseFile)
373
+ expect(patch.files).toContain(cyrillicFile)
374
+
375
+ await Snapshot.revert([patch])
376
+
377
+ expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese")
378
+ expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic")
379
+ },
380
+ })
381
+ })
382
+
383
+ test("unicode filenames in subdirectories", async () => {
384
+ await using tmp = await bootstrap()
385
+ await Instance.provide({
386
+ directory: tmp.path,
387
+ fn: async () => {
388
+ const before = await Snapshot.track()
389
+ expect(before).toBeTruthy()
390
+
391
+ await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet()
392
+ const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt")
393
+ await Filesystem.write(deepFile, "deep unicode content")
394
+
395
+ const patch = await Snapshot.patch(before!)
396
+ expect(patch.files).toContain(deepFile)
397
+
398
+ await Snapshot.revert([patch])
399
+ expect(
400
+ await fs
401
+ .access(deepFile)
402
+ .then(() => true)
403
+ .catch(() => false),
404
+ ).toBe(false)
405
+ },
406
+ })
407
+ })
408
+
409
+ test("very long filenames", async () => {
410
+ await using tmp = await bootstrap()
411
+ await Instance.provide({
412
+ directory: tmp.path,
413
+ fn: async () => {
414
+ const before = await Snapshot.track()
415
+ expect(before).toBeTruthy()
416
+
417
+ const longName = "a".repeat(200) + ".txt"
418
+ const longFile = fwd(tmp.path, longName)
419
+
420
+ await Filesystem.write(longFile, "long filename content")
421
+
422
+ const patch = await Snapshot.patch(before!)
423
+ expect(patch.files).toContain(longFile)
424
+
425
+ await Snapshot.revert([patch])
426
+ expect(
427
+ await fs
428
+ .access(longFile)
429
+ .then(() => true)
430
+ .catch(() => false),
431
+ ).toBe(false)
432
+ },
433
+ })
434
+ })
435
+
436
+ test("hidden files", async () => {
437
+ await using tmp = await bootstrap()
438
+ await Instance.provide({
439
+ directory: tmp.path,
440
+ fn: async () => {
441
+ const before = await Snapshot.track()
442
+ expect(before).toBeTruthy()
443
+
444
+ await Filesystem.write(`${tmp.path}/.hidden`, "hidden content")
445
+ await Filesystem.write(`${tmp.path}/.gitignore`, "*.log")
446
+ await Filesystem.write(`${tmp.path}/.config`, "config content")
447
+
448
+ const patch = await Snapshot.patch(before!)
449
+ expect(patch.files).toContain(fwd(tmp.path, ".hidden"))
450
+ expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
451
+ expect(patch.files).toContain(fwd(tmp.path, ".config"))
452
+ },
453
+ })
454
+ })
455
+
456
+ test("nested symlinks", async () => {
457
+ await using tmp = await bootstrap()
458
+ await Instance.provide({
459
+ directory: tmp.path,
460
+ fn: async () => {
461
+ const before = await Snapshot.track()
462
+ expect(before).toBeTruthy()
463
+
464
+ await $`mkdir -p ${tmp.path}/sub/dir`.quiet()
465
+ await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content")
466
+ await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")
467
+ await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")
468
+
469
+ const patch = await Snapshot.patch(before!)
470
+ expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt"))
471
+ expect(patch.files).toContain(fwd(tmp.path, "sub-link"))
472
+ },
473
+ })
474
+ })
475
+
476
+ test("file permissions and ownership changes", async () => {
477
+ await using tmp = await bootstrap()
478
+ await Instance.provide({
479
+ directory: tmp.path,
480
+ fn: async () => {
481
+ const before = await Snapshot.track()
482
+ expect(before).toBeTruthy()
483
+
484
+ // Change permissions multiple times
485
+ await $`chmod 600 ${tmp.path}/a.txt`.quiet()
486
+ await $`chmod 755 ${tmp.path}/a.txt`.quiet()
487
+ await $`chmod 644 ${tmp.path}/a.txt`.quiet()
488
+
489
+ const patch = await Snapshot.patch(before!)
490
+ // Note: git doesn't track permission changes on existing files by default
491
+ // Only tracks executable bit when files are first added
492
+ expect(patch.files.length).toBe(0)
493
+ },
494
+ })
495
+ })
496
+
497
+ test("circular symlinks", async () => {
498
+ await using tmp = await bootstrap()
499
+ await Instance.provide({
500
+ directory: tmp.path,
501
+ fn: async () => {
502
+ const before = await Snapshot.track()
503
+ expect(before).toBeTruthy()
504
+
505
+ // Create circular symlink
506
+ await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {})
507
+
508
+ const patch = await Snapshot.patch(before!)
509
+ expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
510
+ },
511
+ })
512
+ })
513
+
514
+ test("gitignore changes", async () => {
515
+ await using tmp = await bootstrap()
516
+ await Instance.provide({
517
+ directory: tmp.path,
518
+ fn: async () => {
519
+ const before = await Snapshot.track()
520
+ expect(before).toBeTruthy()
521
+
522
+ await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored")
523
+ await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content")
524
+ await Filesystem.write(`${tmp.path}/normal.txt`, "normal content")
525
+
526
+ const patch = await Snapshot.patch(before!)
527
+
528
+ // Should track gitignore itself
529
+ expect(patch.files).toContain(fwd(tmp.path, ".gitignore"))
530
+ // Should track normal files
531
+ expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
532
+ // Should not track ignored files (git won't see them)
533
+ expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored"))
534
+ },
535
+ })
536
+ })
537
+
538
+ test("git info exclude changes", async () => {
539
+ await using tmp = await bootstrap()
540
+ await Instance.provide({
541
+ directory: tmp.path,
542
+ fn: async () => {
543
+ const before = await Snapshot.track()
544
+ expect(before).toBeTruthy()
545
+
546
+ const file = `${tmp.path}/.git/info/exclude`
547
+ const text = await Bun.file(file).text()
548
+ await Bun.write(file, `${text.trimEnd()}\nignored.txt\n`)
549
+ await Bun.write(`${tmp.path}/ignored.txt`, "ignored content")
550
+ await Bun.write(`${tmp.path}/normal.txt`, "normal content")
551
+
552
+ const patch = await Snapshot.patch(before!)
553
+ expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
554
+ expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt"))
555
+
556
+ const after = await Snapshot.track()
557
+ const diffs = await Snapshot.diffFull(before!, after!)
558
+ expect(diffs.some((x) => x.file === "normal.txt")).toBe(true)
559
+ expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false)
560
+ },
561
+ })
562
+ })
563
+
564
+ test("git info exclude keeps global excludes", async () => {
565
+ await using tmp = await bootstrap()
566
+ await Instance.provide({
567
+ directory: tmp.path,
568
+ fn: async () => {
569
+ const global = `${tmp.path}/global.ignore`
570
+ const config = `${tmp.path}/global.gitconfig`
571
+ await Bun.write(global, "global.tmp\n")
572
+ await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`)
573
+
574
+ const prev = process.env.GIT_CONFIG_GLOBAL
575
+ process.env.GIT_CONFIG_GLOBAL = config
576
+ try {
577
+ const before = await Snapshot.track()
578
+ expect(before).toBeTruthy()
579
+
580
+ const file = `${tmp.path}/.git/info/exclude`
581
+ const text = await Bun.file(file).text()
582
+ await Bun.write(file, `${text.trimEnd()}\ninfo.tmp\n`)
583
+
584
+ await Bun.write(`${tmp.path}/global.tmp`, "global content")
585
+ await Bun.write(`${tmp.path}/info.tmp`, "info content")
586
+ await Bun.write(`${tmp.path}/normal.txt`, "normal content")
587
+
588
+ const patch = await Snapshot.patch(before!)
589
+ expect(patch.files).toContain(fwd(tmp.path, "normal.txt"))
590
+ expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp"))
591
+ expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp"))
592
+ } finally {
593
+ if (prev) process.env.GIT_CONFIG_GLOBAL = prev
594
+ else delete process.env.GIT_CONFIG_GLOBAL
595
+ }
596
+ },
597
+ })
598
+ })
599
+
600
+ test("concurrent file operations during patch", async () => {
601
+ await using tmp = await bootstrap()
602
+ await Instance.provide({
603
+ directory: tmp.path,
604
+ fn: async () => {
605
+ const before = await Snapshot.track()
606
+ expect(before).toBeTruthy()
607
+
608
+ // Start creating files
609
+ const createPromise = (async () => {
610
+ for (let i = 0; i < 10; i++) {
611
+ await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`)
612
+ // Small delay to simulate concurrent operations
613
+ await new Promise((resolve) => setTimeout(resolve, 1))
614
+ }
615
+ })()
616
+
617
+ // Get patch while files are being created
618
+ const patchPromise = Snapshot.patch(before!)
619
+
620
+ await createPromise
621
+ const patch = await patchPromise
622
+
623
+ // Should capture some or all of the concurrent files
624
+ expect(patch.files.length).toBeGreaterThanOrEqual(0)
625
+ },
626
+ })
627
+ })
628
+
629
+ test("snapshot state isolation between projects", async () => {
630
+ // Test that different projects don't interfere with each other
631
+ await using tmp1 = await bootstrap()
632
+ await using tmp2 = await bootstrap()
633
+
634
+ await Instance.provide({
635
+ directory: tmp1.path,
636
+ fn: async () => {
637
+ const before1 = await Snapshot.track()
638
+ await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content")
639
+ const patch1 = await Snapshot.patch(before1!)
640
+ expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt"))
641
+ },
642
+ })
643
+
644
+ await Instance.provide({
645
+ directory: tmp2.path,
646
+ fn: async () => {
647
+ const before2 = await Snapshot.track()
648
+ await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content")
649
+ const patch2 = await Snapshot.patch(before2!)
650
+ expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt"))
651
+
652
+ // Ensure project1 files don't appear in project2
653
+ expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt"))
654
+ },
655
+ })
656
+ })
657
+
658
+ test("patch detects changes in secondary worktree", async () => {
659
+ await using tmp = await bootstrap()
660
+ const worktreePath = `${tmp.path}-worktree`
661
+ await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
662
+
663
+ try {
664
+ await Instance.provide({
665
+ directory: tmp.path,
666
+ fn: async () => {
667
+ expect(await Snapshot.track()).toBeTruthy()
668
+ },
669
+ })
670
+
671
+ await Instance.provide({
672
+ directory: worktreePath,
673
+ fn: async () => {
674
+ const before = await Snapshot.track()
675
+ expect(before).toBeTruthy()
676
+
677
+ const worktreeFile = fwd(worktreePath, "worktree.txt")
678
+ await Filesystem.write(worktreeFile, "worktree content")
679
+
680
+ const patch = await Snapshot.patch(before!)
681
+ expect(patch.files).toContain(worktreeFile)
682
+ },
683
+ })
684
+ } finally {
685
+ await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
686
+ await $`rm -rf ${worktreePath}`.quiet()
687
+ }
688
+ })
689
+
690
+ test("revert only removes files in invoking worktree", async () => {
691
+ await using tmp = await bootstrap()
692
+ const worktreePath = `${tmp.path}-worktree`
693
+ await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
694
+
695
+ try {
696
+ await Instance.provide({
697
+ directory: tmp.path,
698
+ fn: async () => {
699
+ expect(await Snapshot.track()).toBeTruthy()
700
+ },
701
+ })
702
+ const primaryFile = `${tmp.path}/worktree.txt`
703
+ await Filesystem.write(primaryFile, "primary content")
704
+
705
+ await Instance.provide({
706
+ directory: worktreePath,
707
+ fn: async () => {
708
+ const before = await Snapshot.track()
709
+ expect(before).toBeTruthy()
710
+
711
+ const worktreeFile = fwd(worktreePath, "worktree.txt")
712
+ await Filesystem.write(worktreeFile, "worktree content")
713
+
714
+ const patch = await Snapshot.patch(before!)
715
+ await Snapshot.revert([patch])
716
+
717
+ expect(
718
+ await fs
719
+ .access(worktreeFile)
720
+ .then(() => true)
721
+ .catch(() => false),
722
+ ).toBe(false)
723
+ },
724
+ })
725
+
726
+ expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content")
727
+ } finally {
728
+ await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
729
+ await $`rm -rf ${worktreePath}`.quiet()
730
+ await $`rm -f ${tmp.path}/worktree.txt`.quiet()
731
+ }
732
+ })
733
+
734
+ test("diff reports worktree-only/shared edits and ignores primary-only", async () => {
735
+ await using tmp = await bootstrap()
736
+ const worktreePath = `${tmp.path}-worktree`
737
+ await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet()
738
+
739
+ try {
740
+ await Instance.provide({
741
+ directory: tmp.path,
742
+ fn: async () => {
743
+ expect(await Snapshot.track()).toBeTruthy()
744
+ },
745
+ })
746
+
747
+ await Instance.provide({
748
+ directory: worktreePath,
749
+ fn: async () => {
750
+ const before = await Snapshot.track()
751
+ expect(before).toBeTruthy()
752
+
753
+ await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content")
754
+ await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit")
755
+ await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit")
756
+ await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change")
757
+
758
+ const diff = await Snapshot.diff(before!)
759
+ expect(diff).toContain("worktree-only.txt")
760
+ expect(diff).toContain("shared.txt")
761
+ expect(diff).not.toContain("primary-only.txt")
762
+ },
763
+ })
764
+ } finally {
765
+ await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow()
766
+ await $`rm -rf ${worktreePath}`.quiet()
767
+ await $`rm -f ${tmp.path}/shared.txt`.quiet()
768
+ await $`rm -f ${tmp.path}/primary-only.txt`.quiet()
769
+ }
770
+ })
771
+
772
+ test("track with no changes returns same hash", async () => {
773
+ await using tmp = await bootstrap()
774
+ await Instance.provide({
775
+ directory: tmp.path,
776
+ fn: async () => {
777
+ const hash1 = await Snapshot.track()
778
+ expect(hash1).toBeTruthy()
779
+
780
+ // Track again with no changes
781
+ const hash2 = await Snapshot.track()
782
+ expect(hash2).toBe(hash1!)
783
+
784
+ // Track again
785
+ const hash3 = await Snapshot.track()
786
+ expect(hash3).toBe(hash1!)
787
+ },
788
+ })
789
+ })
790
+
791
+ test("diff function with various changes", async () => {
792
+ await using tmp = await bootstrap()
793
+ await Instance.provide({
794
+ directory: tmp.path,
795
+ fn: async () => {
796
+ const before = await Snapshot.track()
797
+ expect(before).toBeTruthy()
798
+
799
+ // Make various changes
800
+ await $`rm ${tmp.path}/a.txt`.quiet()
801
+ await Filesystem.write(`${tmp.path}/new.txt`, "new content")
802
+ await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
803
+
804
+ const diff = await Snapshot.diff(before!)
805
+ expect(diff).toContain("a.txt")
806
+ expect(diff).toContain("b.txt")
807
+ expect(diff).toContain("new.txt")
808
+ },
809
+ })
810
+ })
811
+
812
+ test("restore function", async () => {
813
+ await using tmp = await bootstrap()
814
+ await Instance.provide({
815
+ directory: tmp.path,
816
+ fn: async () => {
817
+ const before = await Snapshot.track()
818
+ expect(before).toBeTruthy()
819
+
820
+ // Make changes
821
+ await $`rm ${tmp.path}/a.txt`.quiet()
822
+ await Filesystem.write(`${tmp.path}/new.txt`, "new content")
823
+ await Filesystem.write(`${tmp.path}/b.txt`, "modified")
824
+
825
+ // Restore to original state
826
+ await Snapshot.restore(before!)
827
+
828
+ expect(
829
+ await fs
830
+ .access(`${tmp.path}/a.txt`)
831
+ .then(() => true)
832
+ .catch(() => false),
833
+ ).toBe(true)
834
+ expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent)
835
+ expect(
836
+ await fs
837
+ .access(`${tmp.path}/new.txt`)
838
+ .then(() => true)
839
+ .catch(() => false),
840
+ ).toBe(true) // New files should remain
841
+ expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent)
842
+ },
843
+ })
844
+ })
845
+
846
+ test("revert should not delete files that existed but were deleted in snapshot", async () => {
847
+ await using tmp = await bootstrap()
848
+ await Instance.provide({
849
+ directory: tmp.path,
850
+ fn: async () => {
851
+ const snapshot1 = await Snapshot.track()
852
+ expect(snapshot1).toBeTruthy()
853
+
854
+ await $`rm ${tmp.path}/a.txt`.quiet()
855
+
856
+ const snapshot2 = await Snapshot.track()
857
+ expect(snapshot2).toBeTruthy()
858
+
859
+ await Filesystem.write(`${tmp.path}/a.txt`, "recreated content")
860
+
861
+ const patch = await Snapshot.patch(snapshot2!)
862
+ expect(patch.files).toContain(fwd(tmp.path, "a.txt"))
863
+
864
+ await Snapshot.revert([patch])
865
+
866
+ expect(
867
+ await fs
868
+ .access(`${tmp.path}/a.txt`)
869
+ .then(() => true)
870
+ .catch(() => false),
871
+ ).toBe(false)
872
+ },
873
+ })
874
+ })
875
+
876
+ test("revert preserves file that existed in snapshot when deleted then recreated", async () => {
877
+ await using tmp = await bootstrap()
878
+ await Instance.provide({
879
+ directory: tmp.path,
880
+ fn: async () => {
881
+ await Filesystem.write(`${tmp.path}/existing.txt`, "original content")
882
+
883
+ const snapshot = await Snapshot.track()
884
+ expect(snapshot).toBeTruthy()
885
+
886
+ await $`rm ${tmp.path}/existing.txt`.quiet()
887
+ await Filesystem.write(`${tmp.path}/existing.txt`, "recreated")
888
+ await Filesystem.write(`${tmp.path}/newfile.txt`, "new")
889
+
890
+ const patch = await Snapshot.patch(snapshot!)
891
+ expect(patch.files).toContain(fwd(tmp.path, "existing.txt"))
892
+ expect(patch.files).toContain(fwd(tmp.path, "newfile.txt"))
893
+
894
+ await Snapshot.revert([patch])
895
+
896
+ expect(
897
+ await fs
898
+ .access(`${tmp.path}/newfile.txt`)
899
+ .then(() => true)
900
+ .catch(() => false),
901
+ ).toBe(false)
902
+ expect(
903
+ await fs
904
+ .access(`${tmp.path}/existing.txt`)
905
+ .then(() => true)
906
+ .catch(() => false),
907
+ ).toBe(true)
908
+ expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content")
909
+ },
910
+ })
911
+ })
912
+
913
+ test("diffFull sets status based on git change type", async () => {
914
+ await using tmp = await bootstrap()
915
+ await Instance.provide({
916
+ directory: tmp.path,
917
+ fn: async () => {
918
+ await Filesystem.write(`${tmp.path}/grow.txt`, "one\n")
919
+ await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
920
+ await Filesystem.write(`${tmp.path}/delete.txt`, "gone")
921
+
922
+ const before = await Snapshot.track()
923
+ expect(before).toBeTruthy()
924
+
925
+ await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
926
+ await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n")
927
+ await $`rm ${tmp.path}/delete.txt`.quiet()
928
+ await Filesystem.write(`${tmp.path}/added.txt`, "new")
929
+
930
+ const after = await Snapshot.track()
931
+ expect(after).toBeTruthy()
932
+
933
+ const diffs = await Snapshot.diffFull(before!, after!)
934
+ expect(diffs.length).toBe(4)
935
+
936
+ const added = diffs.find((d) => d.file === "added.txt")
937
+ expect(added).toBeDefined()
938
+ expect(added!.status).toBe("added")
939
+
940
+ const deleted = diffs.find((d) => d.file === "delete.txt")
941
+ expect(deleted).toBeDefined()
942
+ expect(deleted!.status).toBe("deleted")
943
+
944
+ const grow = diffs.find((d) => d.file === "grow.txt")
945
+ expect(grow).toBeDefined()
946
+ expect(grow!.status).toBe("modified")
947
+ expect(grow!.additions).toBeGreaterThan(0)
948
+ expect(grow!.deletions).toBe(0)
949
+
950
+ const trim = diffs.find((d) => d.file === "trim.txt")
951
+ expect(trim).toBeDefined()
952
+ expect(trim!.status).toBe("modified")
953
+ expect(trim!.additions).toBe(0)
954
+ expect(trim!.deletions).toBeGreaterThan(0)
955
+ },
956
+ })
957
+ })
958
+
959
+ test("diffFull with new file additions", async () => {
960
+ await using tmp = await bootstrap()
961
+ await Instance.provide({
962
+ directory: tmp.path,
963
+ fn: async () => {
964
+ const before = await Snapshot.track()
965
+ expect(before).toBeTruthy()
966
+
967
+ await Filesystem.write(`${tmp.path}/new.txt`, "new content")
968
+
969
+ const after = await Snapshot.track()
970
+ expect(after).toBeTruthy()
971
+
972
+ const diffs = await Snapshot.diffFull(before!, after!)
973
+ expect(diffs.length).toBe(1)
974
+
975
+ const newFileDiff = diffs[0]
976
+ expect(newFileDiff.file).toBe("new.txt")
977
+ expect(newFileDiff.before).toBe("")
978
+ expect(newFileDiff.after).toBe("new content")
979
+ expect(newFileDiff.additions).toBe(1)
980
+ expect(newFileDiff.deletions).toBe(0)
981
+ },
982
+ })
983
+ })
984
+
985
+ test("diffFull with file modifications", async () => {
986
+ await using tmp = await bootstrap()
987
+ await Instance.provide({
988
+ directory: tmp.path,
989
+ fn: async () => {
990
+ const before = await Snapshot.track()
991
+ expect(before).toBeTruthy()
992
+
993
+ await Filesystem.write(`${tmp.path}/b.txt`, "modified content")
994
+
995
+ const after = await Snapshot.track()
996
+ expect(after).toBeTruthy()
997
+
998
+ const diffs = await Snapshot.diffFull(before!, after!)
999
+ expect(diffs.length).toBe(1)
1000
+
1001
+ const modifiedFileDiff = diffs[0]
1002
+ expect(modifiedFileDiff.file).toBe("b.txt")
1003
+ expect(modifiedFileDiff.before).toBe(tmp.extra.bContent)
1004
+ expect(modifiedFileDiff.after).toBe("modified content")
1005
+ expect(modifiedFileDiff.additions).toBeGreaterThan(0)
1006
+ expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
1007
+ },
1008
+ })
1009
+ })
1010
+
1011
+ test("diffFull with file deletions", async () => {
1012
+ await using tmp = await bootstrap()
1013
+ await Instance.provide({
1014
+ directory: tmp.path,
1015
+ fn: async () => {
1016
+ const before = await Snapshot.track()
1017
+ expect(before).toBeTruthy()
1018
+
1019
+ await $`rm ${tmp.path}/a.txt`.quiet()
1020
+
1021
+ const after = await Snapshot.track()
1022
+ expect(after).toBeTruthy()
1023
+
1024
+ const diffs = await Snapshot.diffFull(before!, after!)
1025
+ expect(diffs.length).toBe(1)
1026
+
1027
+ const removedFileDiff = diffs[0]
1028
+ expect(removedFileDiff.file).toBe("a.txt")
1029
+ expect(removedFileDiff.before).toBe(tmp.extra.aContent)
1030
+ expect(removedFileDiff.after).toBe("")
1031
+ expect(removedFileDiff.additions).toBe(0)
1032
+ expect(removedFileDiff.deletions).toBe(1)
1033
+ },
1034
+ })
1035
+ })
1036
+
1037
+ test("diffFull with multiple line additions", async () => {
1038
+ await using tmp = await bootstrap()
1039
+ await Instance.provide({
1040
+ directory: tmp.path,
1041
+ fn: async () => {
1042
+ const before = await Snapshot.track()
1043
+ expect(before).toBeTruthy()
1044
+
1045
+ await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3")
1046
+
1047
+ const after = await Snapshot.track()
1048
+ expect(after).toBeTruthy()
1049
+
1050
+ const diffs = await Snapshot.diffFull(before!, after!)
1051
+ expect(diffs.length).toBe(1)
1052
+
1053
+ const multiDiff = diffs[0]
1054
+ expect(multiDiff.file).toBe("multi.txt")
1055
+ expect(multiDiff.before).toBe("")
1056
+ expect(multiDiff.after).toBe("line1\nline2\nline3")
1057
+ expect(multiDiff.additions).toBe(3)
1058
+ expect(multiDiff.deletions).toBe(0)
1059
+ },
1060
+ })
1061
+ })
1062
+
1063
+ test("diffFull with addition and deletion", async () => {
1064
+ await using tmp = await bootstrap()
1065
+ await Instance.provide({
1066
+ directory: tmp.path,
1067
+ fn: async () => {
1068
+ const before = await Snapshot.track()
1069
+ expect(before).toBeTruthy()
1070
+
1071
+ await Filesystem.write(`${tmp.path}/added.txt`, "added content")
1072
+ await $`rm ${tmp.path}/a.txt`.quiet()
1073
+
1074
+ const after = await Snapshot.track()
1075
+ expect(after).toBeTruthy()
1076
+
1077
+ const diffs = await Snapshot.diffFull(before!, after!)
1078
+ expect(diffs.length).toBe(2)
1079
+
1080
+ const addedFileDiff = diffs.find((d) => d.file === "added.txt")
1081
+ expect(addedFileDiff).toBeDefined()
1082
+ expect(addedFileDiff!.before).toBe("")
1083
+ expect(addedFileDiff!.after).toBe("added content")
1084
+ expect(addedFileDiff!.additions).toBe(1)
1085
+ expect(addedFileDiff!.deletions).toBe(0)
1086
+
1087
+ const removedFileDiff = diffs.find((d) => d.file === "a.txt")
1088
+ expect(removedFileDiff).toBeDefined()
1089
+ expect(removedFileDiff!.before).toBe(tmp.extra.aContent)
1090
+ expect(removedFileDiff!.after).toBe("")
1091
+ expect(removedFileDiff!.additions).toBe(0)
1092
+ expect(removedFileDiff!.deletions).toBe(1)
1093
+ },
1094
+ })
1095
+ })
1096
+
1097
+ test("diffFull with multiple additions and deletions", async () => {
1098
+ await using tmp = await bootstrap()
1099
+ await Instance.provide({
1100
+ directory: tmp.path,
1101
+ fn: async () => {
1102
+ const before = await Snapshot.track()
1103
+ expect(before).toBeTruthy()
1104
+
1105
+ await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3")
1106
+ await Filesystem.write(`${tmp.path}/multi2.txt`, "single line")
1107
+ await $`rm ${tmp.path}/a.txt`.quiet()
1108
+ await $`rm ${tmp.path}/b.txt`.quiet()
1109
+
1110
+ const after = await Snapshot.track()
1111
+ expect(after).toBeTruthy()
1112
+
1113
+ const diffs = await Snapshot.diffFull(before!, after!)
1114
+ expect(diffs.length).toBe(4)
1115
+
1116
+ const multi1Diff = diffs.find((d) => d.file === "multi1.txt")
1117
+ expect(multi1Diff).toBeDefined()
1118
+ expect(multi1Diff!.additions).toBe(3)
1119
+ expect(multi1Diff!.deletions).toBe(0)
1120
+
1121
+ const multi2Diff = diffs.find((d) => d.file === "multi2.txt")
1122
+ expect(multi2Diff).toBeDefined()
1123
+ expect(multi2Diff!.additions).toBe(1)
1124
+ expect(multi2Diff!.deletions).toBe(0)
1125
+
1126
+ const removedADiff = diffs.find((d) => d.file === "a.txt")
1127
+ expect(removedADiff).toBeDefined()
1128
+ expect(removedADiff!.additions).toBe(0)
1129
+ expect(removedADiff!.deletions).toBe(1)
1130
+
1131
+ const removedBDiff = diffs.find((d) => d.file === "b.txt")
1132
+ expect(removedBDiff).toBeDefined()
1133
+ expect(removedBDiff!.additions).toBe(0)
1134
+ expect(removedBDiff!.deletions).toBe(1)
1135
+ },
1136
+ })
1137
+ })
1138
+
1139
+ test("diffFull with no changes", async () => {
1140
+ await using tmp = await bootstrap()
1141
+ await Instance.provide({
1142
+ directory: tmp.path,
1143
+ fn: async () => {
1144
+ const before = await Snapshot.track()
1145
+ expect(before).toBeTruthy()
1146
+
1147
+ const after = await Snapshot.track()
1148
+ expect(after).toBeTruthy()
1149
+
1150
+ const diffs = await Snapshot.diffFull(before!, after!)
1151
+ expect(diffs.length).toBe(0)
1152
+ },
1153
+ })
1154
+ })
1155
+
1156
+ test("diffFull with binary file changes", async () => {
1157
+ await using tmp = await bootstrap()
1158
+ await Instance.provide({
1159
+ directory: tmp.path,
1160
+ fn: async () => {
1161
+ const before = await Snapshot.track()
1162
+ expect(before).toBeTruthy()
1163
+
1164
+ await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03]))
1165
+
1166
+ const after = await Snapshot.track()
1167
+ expect(after).toBeTruthy()
1168
+
1169
+ const diffs = await Snapshot.diffFull(before!, after!)
1170
+ expect(diffs.length).toBe(1)
1171
+
1172
+ const binaryDiff = diffs[0]
1173
+ expect(binaryDiff.file).toBe("binary.bin")
1174
+ expect(binaryDiff.before).toBe("")
1175
+ },
1176
+ })
1177
+ })
1178
+
1179
+ test("diffFull with whitespace changes", async () => {
1180
+ await using tmp = await bootstrap()
1181
+ await Instance.provide({
1182
+ directory: tmp.path,
1183
+ fn: async () => {
1184
+ await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2")
1185
+ const before = await Snapshot.track()
1186
+ expect(before).toBeTruthy()
1187
+
1188
+ await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n")
1189
+
1190
+ const after = await Snapshot.track()
1191
+ expect(after).toBeTruthy()
1192
+
1193
+ const diffs = await Snapshot.diffFull(before!, after!)
1194
+ expect(diffs.length).toBe(1)
1195
+
1196
+ const whitespaceDiff = diffs[0]
1197
+ expect(whitespaceDiff.file).toBe("whitespace.txt")
1198
+ expect(whitespaceDiff.additions).toBeGreaterThan(0)
1199
+ },
1200
+ })
1201
+ })
1202
+
1203
+ test("revert with overlapping files across patches uses first patch hash", async () => {
1204
+ await using tmp = await bootstrap()
1205
+ await Instance.provide({
1206
+ directory: tmp.path,
1207
+ fn: async () => {
1208
+ // Write initial content and snapshot
1209
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v1")
1210
+ const snap1 = await Snapshot.track()
1211
+ expect(snap1).toBeTruthy()
1212
+
1213
+ // Modify and snapshot again
1214
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v2")
1215
+ const snap2 = await Snapshot.track()
1216
+ expect(snap2).toBeTruthy()
1217
+
1218
+ // Modify once more so both patches include shared.txt
1219
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v3")
1220
+
1221
+ const patch1 = await Snapshot.patch(snap1!)
1222
+ const patch2 = await Snapshot.patch(snap2!)
1223
+
1224
+ // Both patches should include shared.txt
1225
+ expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
1226
+ expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
1227
+
1228
+ // Revert with patch1 first — should use snap1's hash (restoring "v1")
1229
+ await Snapshot.revert([patch1, patch2])
1230
+
1231
+ const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8")
1232
+ expect(content).toBe("v1")
1233
+ },
1234
+ })
1235
+ })