@stonerzju/opencode 1.2.16-offline.1

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