@otto-assistant/bridge 0.4.92

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 (483) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. package/src/xml.ts +121 -0
@@ -0,0 +1,1294 @@
1
+ // Worktree service and git helpers.
2
+ // Provides reusable, Discord-agnostic worktree creation/merge logic,
3
+ // submodule initialization, and git diff transfer utilities.
4
+
5
+ import crypto from 'node:crypto'
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import * as errore from 'errore'
10
+ import { execAsync } from './exec-async.js'
11
+ import { createLogger, LogPrefix } from './logger.js'
12
+
13
+ export { execAsync } from './exec-async.js'
14
+
15
+ const SUBMODULE_INIT_TIMEOUT_MS = 20 * 60_000
16
+ const INSTALL_TIMEOUT_MS = 60_000
17
+
18
+ const logger = createLogger(LogPrefix.WORKTREE)
19
+
20
+ const LOCKFILE_TO_INSTALL_COMMAND: Array<[string, string]> = [
21
+ ['pnpm-lock.yaml', 'pnpm install'],
22
+ ['bun.lock', 'bun install'],
23
+ ['bun.lockb', 'bun install'],
24
+ ['yarn.lock', 'yarn install'],
25
+ ['package-lock.json', 'npm install'],
26
+ ]
27
+
28
+ function detectInstallCommand(directory: string): string | null {
29
+ for (const [lockfile, command] of LOCKFILE_TO_INSTALL_COMMAND) {
30
+ if (fs.existsSync(path.join(directory, lockfile))) {
31
+ return command
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ /**
38
+ * Run the detected package manager install in a worktree directory.
39
+ * Non-fatal: returns Error on failure/timeout so callers can log and continue.
40
+ * The 60s timeout kills the process if install hangs.
41
+ */
42
+ export async function runDependencyInstall({
43
+ directory,
44
+ }: {
45
+ directory: string
46
+ }): Promise<void | Error> {
47
+ const installCommand = detectInstallCommand(directory)
48
+ if (!installCommand) {
49
+ return
50
+ }
51
+ logger.log(`Running "${installCommand}" in ${directory} (timeout=${INSTALL_TIMEOUT_MS}ms)`)
52
+ try {
53
+ await execAsync(installCommand, {
54
+ cwd: directory,
55
+ timeout: INSTALL_TIMEOUT_MS,
56
+ })
57
+ logger.log(`Dependencies installed in ${directory}`)
58
+ } catch (e) {
59
+ return new Error(`Install failed: ${formatCommandError(e)}`, { cause: e })
60
+ }
61
+ }
62
+
63
+ type CommandError = Error & {
64
+ cmd?: string
65
+ stderr?: string
66
+ stdout?: string
67
+ signal?: NodeJS.Signals
68
+ killed?: boolean
69
+ }
70
+
71
+ function formatCommandError(error: unknown): string {
72
+ if (!(error instanceof Error)) {
73
+ return String(error)
74
+ }
75
+
76
+ const commandError = error as CommandError
77
+ const details: string[] = [commandError.message]
78
+
79
+ if (commandError.cmd) {
80
+ details.push(`cmd=${commandError.cmd}`)
81
+ }
82
+ if (commandError.signal) {
83
+ details.push(`signal=${commandError.signal}`)
84
+ }
85
+ if (commandError.killed) {
86
+ details.push('process=killed')
87
+ }
88
+ if (commandError.stderr?.trim()) {
89
+ details.push(`stderr=${commandError.stderr.trim()}`)
90
+ }
91
+ if (commandError.stdout?.trim()) {
92
+ details.push(`stdout=${commandError.stdout.trim()}`)
93
+ }
94
+
95
+ return details.join(' | ')
96
+ }
97
+
98
+ type GitSubmoduleConfig = {
99
+ name: string
100
+ path: string
101
+ url: string | null
102
+ }
103
+
104
+ export type SubmoduleReferencePlan = {
105
+ path: string
106
+ referenceDirectory: string | null
107
+ }
108
+
109
+ export function parseGitmodulesFileContent(
110
+ gitmodulesContent: string,
111
+ ): GitSubmoduleConfig[] | Error {
112
+ const lines = gitmodulesContent.split('\n')
113
+ const configs: GitSubmoduleConfig[] = []
114
+ let currentName: string | null = null
115
+ let currentPath: string | null = null
116
+ let currentUrl: string | null = null
117
+
118
+ const flushCurrent = (): void | Error => {
119
+ if (!currentName) {
120
+ return
121
+ }
122
+ if (!currentPath) {
123
+ return new Error(`Submodule ${currentName} is missing path in .gitmodules`)
124
+ }
125
+ configs.push({
126
+ name: currentName,
127
+ path: currentPath,
128
+ url: currentUrl,
129
+ })
130
+ }
131
+
132
+ for (const rawLine of lines) {
133
+ const line = rawLine.trim()
134
+ if (!line || line.startsWith('#') || line.startsWith(';')) {
135
+ continue
136
+ }
137
+
138
+ const sectionMatch = line.match(/^\[submodule\s+"([^"]+)"\]$/)
139
+ if (sectionMatch?.[1]) {
140
+ const flushError = flushCurrent()
141
+ if (flushError instanceof Error) {
142
+ return flushError
143
+ }
144
+
145
+ currentName = sectionMatch[1]
146
+ currentPath = null
147
+ currentUrl = null
148
+ continue
149
+ }
150
+
151
+ if (!currentName) {
152
+ continue
153
+ }
154
+
155
+ const keyValueMatch = line.match(/^([^=\s]+)\s*=\s*(.*)$/)
156
+ const key = keyValueMatch?.[1]
157
+ const value = keyValueMatch?.[2]
158
+ if (!key || value === undefined) {
159
+ continue
160
+ }
161
+
162
+ if (key === 'path') {
163
+ currentPath = value
164
+ continue
165
+ }
166
+
167
+ if (key === 'url') {
168
+ currentUrl = value
169
+ }
170
+ }
171
+
172
+ const flushError = flushCurrent()
173
+ if (flushError instanceof Error) {
174
+ return flushError
175
+ }
176
+
177
+ return configs
178
+ }
179
+
180
+ async function readSubmoduleConfigs(
181
+ directory: string,
182
+ ): Promise<GitSubmoduleConfig[] | Error> {
183
+ const gitmodulesPath = path.join(directory, '.gitmodules')
184
+ const gitmodulesExists = await fs.promises
185
+ .access(gitmodulesPath)
186
+ .then(() => {
187
+ return true
188
+ })
189
+ .catch(() => {
190
+ return false
191
+ })
192
+ if (!gitmodulesExists) {
193
+ return []
194
+ }
195
+
196
+ const gitmodulesContent = await errore.tryAsync({
197
+ try: () => fs.promises.readFile(gitmodulesPath, 'utf-8'),
198
+ catch: (e) =>
199
+ new Error(`Failed to read ${gitmodulesPath}`, {
200
+ cause: e,
201
+ }),
202
+ })
203
+ if (gitmodulesContent instanceof Error) {
204
+ return gitmodulesContent
205
+ }
206
+
207
+ const parsed = parseGitmodulesFileContent(gitmodulesContent)
208
+ if (parsed instanceof Error) {
209
+ return new Error(`Failed to parse ${gitmodulesPath}: ${parsed.message}`, {
210
+ cause: parsed,
211
+ })
212
+ }
213
+
214
+ return parsed
215
+ }
216
+
217
+ export function buildSubmoduleReferencePlan({
218
+ sourceDirectory,
219
+ submodulePaths,
220
+ existingSourceSubmoduleDirectories,
221
+ }: {
222
+ sourceDirectory: string
223
+ submodulePaths: string[]
224
+ existingSourceSubmoduleDirectories: Set<string>
225
+ }): SubmoduleReferencePlan[] {
226
+ return submodulePaths.map((submodulePath) => {
227
+ const sourceSubmoduleDirectory = path.resolve(sourceDirectory, submodulePath)
228
+ if (existingSourceSubmoduleDirectories.has(sourceSubmoduleDirectory)) {
229
+ return {
230
+ path: submodulePath,
231
+ referenceDirectory: sourceSubmoduleDirectory,
232
+ }
233
+ }
234
+
235
+ return {
236
+ path: submodulePath,
237
+ referenceDirectory: null,
238
+ }
239
+ })
240
+ }
241
+
242
+ function buildGitCommand(args: string[]): string {
243
+ const quotedArgs = args.map((arg) => {
244
+ return JSON.stringify(arg)
245
+ })
246
+ return `git ${quotedArgs.join(' ')}`
247
+ }
248
+
249
+ export function buildSubmoduleUpdateCommandArgs({
250
+ path: submodulePath,
251
+ referenceDirectory,
252
+ }: SubmoduleReferencePlan): string[] {
253
+ if (referenceDirectory) {
254
+ return [
255
+ '-c',
256
+ 'protocol.file.allow=always',
257
+ 'submodule',
258
+ 'update',
259
+ '--init',
260
+ '--recursive',
261
+ '--reference',
262
+ referenceDirectory,
263
+ '--',
264
+ submodulePath,
265
+ ]
266
+ }
267
+
268
+ return [
269
+ '-c',
270
+ 'protocol.file.allow=always',
271
+ 'submodule',
272
+ 'update',
273
+ '--init',
274
+ '--recursive',
275
+ '--',
276
+ submodulePath,
277
+ ]
278
+ }
279
+
280
+ async function hasSubmoduleGitMetadata(directory: string): Promise<boolean> {
281
+ const gitPath = path.join(directory, '.git')
282
+ return fs.promises
283
+ .access(gitPath)
284
+ .then(() => {
285
+ return true
286
+ })
287
+ .catch(() => {
288
+ return false
289
+ })
290
+ }
291
+
292
+ async function initializeSubmodulesWithLocalReferences({
293
+ sourceDirectory,
294
+ worktreeDirectory,
295
+ }: {
296
+ sourceDirectory: string
297
+ worktreeDirectory: string
298
+ }): Promise<void | Error> {
299
+ const submoduleConfigs = await readSubmoduleConfigs(worktreeDirectory)
300
+ if (submoduleConfigs instanceof Error) {
301
+ return submoduleConfigs
302
+ }
303
+ if (submoduleConfigs.length === 0) {
304
+ return
305
+ }
306
+
307
+ const sourceDirectories = submoduleConfigs.map(({ path: submodulePath }) => {
308
+ return path.resolve(sourceDirectory, submodulePath)
309
+ })
310
+
311
+ const sourceDirectoryChecks = await Promise.all(
312
+ sourceDirectories.map(async (sourceSubmoduleDirectory) => {
313
+ const exists = await hasSubmoduleGitMetadata(sourceSubmoduleDirectory)
314
+ return { sourceSubmoduleDirectory, exists }
315
+ }),
316
+ )
317
+
318
+ const existingSourceSubmoduleDirectories = new Set(
319
+ sourceDirectoryChecks
320
+ .filter(({ exists }) => {
321
+ return exists
322
+ })
323
+ .map(({ sourceSubmoduleDirectory }) => {
324
+ return sourceSubmoduleDirectory
325
+ }),
326
+ )
327
+
328
+ const submodulePlan = buildSubmoduleReferencePlan({
329
+ sourceDirectory,
330
+ submodulePaths: submoduleConfigs.map(({ path: submodulePath }) => {
331
+ return submodulePath
332
+ }),
333
+ existingSourceSubmoduleDirectories,
334
+ })
335
+
336
+ for (const planItem of submodulePlan) {
337
+ const commandArgs = buildSubmoduleUpdateCommandArgs(planItem)
338
+ const command = buildGitCommand(commandArgs)
339
+ const result = await errore.tryAsync({
340
+ try: () =>
341
+ execAsync(command, {
342
+ cwd: worktreeDirectory,
343
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
344
+ }),
345
+ catch: (e) =>
346
+ new Error(
347
+ `git ${commandArgs.join(' ')} failed for ${planItem.path}: ${formatCommandError(e)}`,
348
+ { cause: e },
349
+ ),
350
+ })
351
+ if (result instanceof Error) {
352
+ // Non-fatal: broken .gitmodules entries (e.g. path listed but not in tree)
353
+ // should not block worktree creation. Log and continue with remaining submodules.
354
+ logger.warn(
355
+ `Skipping submodule ${planItem.path}: ${result.message}`,
356
+ )
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Get submodule paths from .gitmodules file.
363
+ * Returns empty array if no submodules or on error.
364
+ */
365
+ async function getSubmodulePaths(directory: string): Promise<string[]> {
366
+ const submoduleConfigs = await readSubmoduleConfigs(directory)
367
+ if (submoduleConfigs instanceof Error) {
368
+ logger.warn(`Failed reading submodules from ${directory}: ${submoduleConfigs.message}`)
369
+ return []
370
+ }
371
+
372
+ return submoduleConfigs.map(({ path: submodulePath }) => {
373
+ return submodulePath
374
+ })
375
+ }
376
+
377
+ /**
378
+ * Remove broken submodule stubs created by git worktree.
379
+ * When git worktree add runs on a repo with submodules, it creates submodule
380
+ * directories with .git files pointing to ../.git/worktrees/<name>/modules/<submodule>
381
+ * but that path only has a config file, missing HEAD/objects/refs.
382
+ * This causes git commands to fail with "fatal: not a git repository".
383
+ */
384
+ async function removeBrokenSubmoduleStubs(directory: string): Promise<void> {
385
+ const submodulePaths = await getSubmodulePaths(directory)
386
+
387
+ for (const subPath of submodulePaths) {
388
+ const fullPath = path.join(directory, subPath)
389
+ const gitFile = path.join(fullPath, '.git')
390
+
391
+ try {
392
+ const stat = await fs.promises.stat(gitFile)
393
+ if (!stat.isFile()) {
394
+ continue
395
+ }
396
+
397
+ // Read .git file to get gitdir path
398
+ const content = await fs.promises.readFile(gitFile, 'utf-8')
399
+ const match = content.match(/^gitdir:\s*(.+)$/m)
400
+ if (!match || !match[1]) {
401
+ continue
402
+ }
403
+
404
+ const gitdir = path.resolve(fullPath, match[1].trim())
405
+ const headFile = path.join(gitdir, 'HEAD')
406
+
407
+ // If HEAD doesn't exist, this is a broken stub
408
+ const headExists = await fs.promises
409
+ .access(headFile)
410
+ .then(() => {
411
+ return true
412
+ })
413
+ .catch(() => {
414
+ return false
415
+ })
416
+
417
+ if (!headExists) {
418
+ logger.log(`Removing broken submodule stub: ${subPath}`)
419
+ await fs.promises.rm(fullPath, { recursive: true, force: true })
420
+ }
421
+ } catch {
422
+ // Directory doesn't exist or other error, skip
423
+ }
424
+ }
425
+ }
426
+
427
+ function parseSubmoduleGitdir(gitFileContent: string): string | Error {
428
+ const match = gitFileContent.match(/^gitdir:\s*(.+)$/m)
429
+ const gitdir = match?.[1]?.trim()
430
+ if (!gitdir) {
431
+ return new Error('Missing gitdir pointer')
432
+ }
433
+ return gitdir
434
+ }
435
+
436
+ async function validateSubmodulePointers(
437
+ directory: string,
438
+ ): Promise<void | Error> {
439
+ const submodulePaths = await getSubmodulePaths(directory)
440
+ if (submodulePaths.length === 0) {
441
+ return
442
+ }
443
+
444
+ const validationIssues: string[] = []
445
+
446
+ await Promise.all(
447
+ submodulePaths.map(async (submodulePath) => {
448
+ const submoduleDir = path.join(directory, submodulePath)
449
+ const submoduleGitFile = path.join(submoduleDir, '.git')
450
+
451
+ const gitFileExists = await fs.promises
452
+ .access(submoduleGitFile)
453
+ .then(() => {
454
+ return true
455
+ })
456
+ .catch(() => {
457
+ return false
458
+ })
459
+ if (!gitFileExists) {
460
+ validationIssues.push(`${submodulePath}: missing .git file`)
461
+ return
462
+ }
463
+
464
+ const gitFileContentResult = await errore.tryAsync({
465
+ try: () => fs.promises.readFile(submoduleGitFile, 'utf-8'),
466
+ catch: (e) =>
467
+ new Error(`Failed to read .git for ${submodulePath}`, { cause: e }),
468
+ })
469
+ if (gitFileContentResult instanceof Error) {
470
+ validationIssues.push(
471
+ `${submodulePath}: ${gitFileContentResult.message}`,
472
+ )
473
+ return
474
+ }
475
+
476
+ const parsedGitdir = parseSubmoduleGitdir(gitFileContentResult)
477
+ if (parsedGitdir instanceof Error) {
478
+ validationIssues.push(`${submodulePath}: ${parsedGitdir.message}`)
479
+ return
480
+ }
481
+
482
+ const resolvedGitdir = path.resolve(submoduleDir, parsedGitdir)
483
+ const headPath = path.join(resolvedGitdir, 'HEAD')
484
+ const headExists = await fs.promises
485
+ .access(headPath)
486
+ .then(() => {
487
+ return true
488
+ })
489
+ .catch(() => {
490
+ return false
491
+ })
492
+ if (!headExists) {
493
+ validationIssues.push(
494
+ `${submodulePath}: gitdir missing HEAD (${resolvedGitdir})`,
495
+ )
496
+ }
497
+ }),
498
+ )
499
+
500
+ const submoduleStatusResult = await errore.tryAsync({
501
+ try: () =>
502
+ execAsync('git submodule status --recursive', {
503
+ cwd: directory,
504
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
505
+ }),
506
+ catch: (e) =>
507
+ new Error('git submodule status --recursive failed', { cause: e }),
508
+ })
509
+ if (submoduleStatusResult instanceof Error) {
510
+ validationIssues.push(submoduleStatusResult.message)
511
+ }
512
+
513
+ if (validationIssues.length === 0) {
514
+ return
515
+ }
516
+
517
+ return new Error(
518
+ `Submodule validation failed: ${validationIssues.join('; ')}`,
519
+ )
520
+ }
521
+
522
+ type WorktreeResult = {
523
+ directory: string
524
+ branch: string
525
+ }
526
+
527
+ async function resolveDefaultWorktreeTarget(
528
+ directory: string,
529
+ ): Promise<string> {
530
+ const remoteHead = await execAsync(
531
+ 'git symbolic-ref refs/remotes/origin/HEAD',
532
+ {
533
+ cwd: directory,
534
+ },
535
+ ).catch(() => {
536
+ return null
537
+ })
538
+
539
+ const remoteRef = remoteHead?.stdout.trim()
540
+ if (remoteRef?.startsWith('refs/remotes/')) {
541
+ return remoteRef.replace('refs/remotes/', '')
542
+ }
543
+
544
+ const hasMain = await execAsync(
545
+ 'git show-ref --verify --quiet refs/heads/main',
546
+ {
547
+ cwd: directory,
548
+ },
549
+ )
550
+ .then(() => {
551
+ return true
552
+ })
553
+ .catch(() => {
554
+ return false
555
+ })
556
+ if (hasMain) {
557
+ return 'main'
558
+ }
559
+
560
+ const hasMaster = await execAsync(
561
+ 'git show-ref --verify --quiet refs/heads/master',
562
+ {
563
+ cwd: directory,
564
+ },
565
+ )
566
+ .then(() => {
567
+ return true
568
+ })
569
+ .catch(() => {
570
+ return false
571
+ })
572
+ if (hasMaster) {
573
+ return 'master'
574
+ }
575
+
576
+ return 'HEAD'
577
+ }
578
+
579
+ function getManagedWorktreeDirectory({
580
+ directory,
581
+ name,
582
+ }: {
583
+ directory: string
584
+ name: string
585
+ }): string {
586
+ const projectHash = crypto.createHash('sha1').update(directory).digest('hex')
587
+ const safeName = name.replaceAll('/', '-')
588
+ return path.join(
589
+ os.homedir(),
590
+ '.local',
591
+ 'share',
592
+ 'opencode',
593
+ 'worktree',
594
+ projectHash,
595
+ safeName,
596
+ )
597
+ }
598
+
599
+ /**
600
+ * Create a worktree using git and initialize git submodules.
601
+ * This wrapper ensures submodules are properly set up in new worktrees.
602
+ */
603
+ export async function createWorktreeWithSubmodules({
604
+ directory,
605
+ name,
606
+ baseBranch,
607
+ onProgress,
608
+ }: {
609
+ directory: string
610
+ name: string
611
+ /** Override the base branch to create the worktree from. Defaults to origin/HEAD → main → master → HEAD. */
612
+ baseBranch?: string
613
+ /** Called with a short phase label so callers can update UI (e.g. Discord status message). */
614
+ onProgress?: (phase: string) => void
615
+ }): Promise<WorktreeResult | Error> {
616
+ // 1. Create worktree via git (checked out immediately).
617
+ const worktreeDir = getManagedWorktreeDirectory({ directory, name })
618
+ const targetRef = baseBranch || (await resolveDefaultWorktreeTarget(directory))
619
+
620
+ if (fs.existsSync(worktreeDir)) {
621
+ return new Error(`Worktree directory already exists: ${worktreeDir}`)
622
+ }
623
+
624
+ await fs.promises.mkdir(path.dirname(worktreeDir), { recursive: true })
625
+
626
+ const createCommand = `git worktree add ${JSON.stringify(worktreeDir)} -B ${JSON.stringify(name)} ${JSON.stringify(targetRef)}`
627
+ const createResult = await errore.tryAsync({
628
+ try: () =>
629
+ execAsync(createCommand, {
630
+ cwd: directory,
631
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
632
+ }),
633
+ catch: (e) =>
634
+ new Error(`git worktree add failed: ${formatCommandError(e)}`, {
635
+ cause: e,
636
+ }),
637
+ })
638
+ if (createResult instanceof Error) {
639
+ return createResult
640
+ }
641
+
642
+ // 2. Remove broken submodule stubs before init
643
+ // git worktree creates stub directories with .git files pointing to incomplete gitdirs
644
+ await removeBrokenSubmoduleStubs(worktreeDir)
645
+
646
+ // 4. Init submodules in new worktree.
647
+ // For each submodule we use git's built-in --reference mechanism when the
648
+ // source checkout already has that submodule cloned. This preserves commit
649
+ // pinning while allowing local-only submodule commits to resolve reliably.
650
+ logger.log(
651
+ `Initializing submodules in ${worktreeDir} (timeout=${SUBMODULE_INIT_TIMEOUT_MS}ms)`,
652
+ )
653
+ const submoduleInitResult = await initializeSubmodulesWithLocalReferences({
654
+ sourceDirectory: directory,
655
+ worktreeDirectory: worktreeDir,
656
+ })
657
+ if (submoduleInitResult instanceof Error) {
658
+ // Non-fatal: log and continue. The worktree itself is already created,
659
+ // only submodule init had issues (e.g. stale .gitmodules entries).
660
+ logger.error('Submodule initialization failed (non-fatal)', {
661
+ worktreeDir,
662
+ timeoutMs: SUBMODULE_INIT_TIMEOUT_MS,
663
+ command: 'git submodule update --init --recursive [--reference ...]',
664
+ error: submoduleInitResult.message,
665
+ })
666
+ } else {
667
+ logger.log(`Submodules initialized in ${worktreeDir}`)
668
+ }
669
+
670
+ // 4.5 Validate submodule pointers and git metadata.
671
+ // Non-fatal: stale .gitmodules entries (path listed but removed from tree)
672
+ // should not block worktree creation.
673
+ const submoduleValidationError = await validateSubmodulePointers(worktreeDir)
674
+ if (submoduleValidationError instanceof Error) {
675
+ logger.error('Submodule validation issues (non-fatal)', {
676
+ worktreeDir,
677
+ error: submoduleValidationError.message,
678
+ })
679
+ }
680
+
681
+ // 5. Dependency install (non-fatal, 60s timeout).
682
+ // Runs the detected package manager install so workspace packages with
683
+ // `prepare` scripts get built (e.g. errore → dist/).
684
+ onProgress?.('Installing dependencies...')
685
+ const installResult = await runDependencyInstall({ directory: worktreeDir })
686
+ if (installResult instanceof Error) {
687
+ logger.error('Dependency install failed (non-fatal)', {
688
+ worktreeDir,
689
+ error: installResult.message,
690
+ })
691
+ }
692
+
693
+ return { directory: worktreeDir, branch: name }
694
+ }
695
+
696
+ // ─── Worktree merge ──────────────────────────────────────────────────────────
697
+ // Merge pipeline (preserves all worktree commits, no squash):
698
+ // 1. Reject if uncommitted changes exist
699
+ // 2. Rebase worktree commits onto target (default branch)
700
+ // 3. Fast-forward push to target via local git push
701
+ // 4. Switch to detached HEAD, delete branch
702
+ //
703
+ // Uses `git push <git-common-dir> HEAD:<target>` with
704
+ // `receive.denyCurrentBranch=updateInstead` to fast-forward the target
705
+ // WITHOUT checking it out in the main repo.
706
+ //
707
+ // Returns MergeWorktreeErrors | MergeSuccess. All errors are tagged via errore.
708
+ // - DirtyWorktreeError → git untouched
709
+ // - NothingToMergeError → git untouched
710
+ // - RebaseConflictError → git left mid-rebase for AI/user resolution
711
+ // - RebaseError → rebase not in progress; temp branch cleaned
712
+ // - NotFastForwardError → source intact; no push
713
+ // - ConflictingFilesError → no push; lists overlapping files
714
+ // - PushError → source rebased but target unchanged
715
+ // - GitCommandError → catch-all for unexpected git failures
716
+
717
+ import {
718
+ DirtyWorktreeError,
719
+ NothingToMergeError,
720
+ RebaseConflictError,
721
+ RebaseError,
722
+ NotFastForwardError,
723
+ ConflictingFilesError,
724
+ PushError,
725
+ GitCommandError,
726
+ type MergeWorktreeErrors,
727
+ } from './errors.js'
728
+
729
+ export type MergeSuccess = {
730
+ defaultBranch: string
731
+ branchName: string
732
+ commitCount: number
733
+ shortSha: string
734
+ }
735
+
736
+ export async function git(
737
+ dir: string,
738
+ args: string,
739
+ opts?: { timeout?: number },
740
+ ): Promise<GitCommandError | string> {
741
+ const result = await errore.tryAsync({
742
+ try: () =>
743
+ execAsync(
744
+ `git -C "${dir}" ${args}`,
745
+ opts ? { timeout: opts.timeout } : undefined,
746
+ ),
747
+ catch: (e) => new GitCommandError({ command: args, cause: e }),
748
+ })
749
+ if (result instanceof Error) {
750
+ return result
751
+ }
752
+ return result.stdout.trim()
753
+ }
754
+
755
+ export async function getDefaultBranch(
756
+ repoDir: string,
757
+ opts?: { timeout?: number },
758
+ ): Promise<string> {
759
+ const ref = await git(repoDir, 'symbolic-ref refs/remotes/origin/HEAD', opts)
760
+ if (ref instanceof Error) {
761
+ return 'main'
762
+ }
763
+ return ref.replace(/^refs\/remotes\/origin\//, '') || 'main'
764
+ }
765
+
766
+ export async function deleteWorktree({
767
+ projectDirectory,
768
+ worktreeDirectory,
769
+ worktreeName,
770
+ }: {
771
+ projectDirectory: string
772
+ worktreeDirectory: string
773
+ worktreeName: string
774
+ }): Promise<void | Error> {
775
+ let removeResult = await git(
776
+ projectDirectory,
777
+ `worktree remove ${JSON.stringify(worktreeDirectory)}`,
778
+ {
779
+ timeout: SUBMODULE_INIT_TIMEOUT_MS,
780
+ },
781
+ )
782
+ // git refuses to remove worktrees with submodule entries:
783
+ // "fatal: working trees containing submodules cannot be moved or removed"
784
+ // Retry with --force which bypasses this guard. This is safe because
785
+ // canDeleteWorktree already verified the worktree is clean and merged.
786
+ if (removeResult instanceof Error) {
787
+ const stderr =
788
+ (removeResult.cause as { stderr?: string } | undefined)?.stderr ?? ''
789
+ if (stderr.includes('containing submodules')) {
790
+ removeResult = await git(
791
+ projectDirectory,
792
+ `worktree remove --force ${JSON.stringify(worktreeDirectory)}`,
793
+ { timeout: SUBMODULE_INIT_TIMEOUT_MS },
794
+ )
795
+ }
796
+ }
797
+ if (removeResult instanceof Error) {
798
+ return new Error(`Failed to remove worktree ${worktreeName}`, {
799
+ cause: removeResult,
800
+ })
801
+ }
802
+
803
+ const deleteBranchResult = await git(
804
+ projectDirectory,
805
+ `branch -d ${JSON.stringify(worktreeName)}`,
806
+ )
807
+ if (deleteBranchResult instanceof Error) {
808
+ return new Error(`Failed to delete branch ${worktreeName}`, {
809
+ cause: deleteBranchResult,
810
+ })
811
+ }
812
+
813
+ const pruneResult = await git(projectDirectory, 'worktree prune')
814
+ if (pruneResult instanceof Error) {
815
+ logger.warn(`Failed to prune worktrees after deleting ${worktreeName}`)
816
+ }
817
+ }
818
+
819
+ export async function isDirty(
820
+ dir: string,
821
+ opts?: { timeout?: number },
822
+ ): Promise<boolean> {
823
+ const status = await git(dir, 'status --porcelain', opts)
824
+ if (status instanceof Error) {
825
+ return false
826
+ }
827
+ return status.length > 0
828
+ }
829
+
830
+ async function getGitCommonDir(dir: string): Promise<GitCommandError | string> {
831
+ const commonDir = await git(dir, 'rev-parse --git-common-dir')
832
+ if (commonDir instanceof Error) {
833
+ return commonDir
834
+ }
835
+ if (path.isAbsolute(commonDir)) {
836
+ return commonDir
837
+ }
838
+ return path.resolve(dir, commonDir)
839
+ }
840
+
841
+ async function isAncestor(
842
+ dir: string,
843
+ ref1: string,
844
+ ref2: string,
845
+ ): Promise<boolean> {
846
+ const result = await git(dir, `merge-base --is-ancestor "${ref1}" "${ref2}"`)
847
+ return !(result instanceof Error)
848
+ }
849
+
850
+ async function isRebasedOnto(dir: string, target: string): Promise<boolean> {
851
+ const mergeBase = await git(dir, `merge-base HEAD "${target}"`)
852
+ if (mergeBase instanceof Error) {
853
+ return false
854
+ }
855
+ const targetSha = await git(dir, `rev-parse "${target}"`)
856
+ if (targetSha instanceof Error) {
857
+ return false
858
+ }
859
+ return mergeBase === targetSha
860
+ }
861
+
862
+ async function getChangedFiles(
863
+ dir: string,
864
+ ref1: string,
865
+ ref2: string,
866
+ ): Promise<string[]> {
867
+ const result = await git(dir, `diff --name-only "${ref1}" "${ref2}"`)
868
+ if (result instanceof Error) {
869
+ return []
870
+ }
871
+ return result.split('\n').filter(Boolean)
872
+ }
873
+
874
+ /**
875
+ * Get dirty files using porcelain -z format.
876
+ * Handles rename/copy entries which emit two NUL-separated paths.
877
+ */
878
+ async function getDirtyFiles(dir: string): Promise<string[]> {
879
+ const result = await git(dir, 'status --porcelain -z')
880
+ if (result instanceof Error) {
881
+ return []
882
+ }
883
+ const files: string[] = []
884
+ const parts = result.split('\0')
885
+ let i = 0
886
+ while (i < parts.length) {
887
+ const entry = parts[i]
888
+ if (!entry || entry.length < 3) {
889
+ i++
890
+ continue
891
+ }
892
+ const status = entry.slice(0, 2)
893
+ const filePath = entry.slice(3)
894
+ if (filePath) {
895
+ files.push(filePath)
896
+ }
897
+ if (
898
+ status[0] === 'R' ||
899
+ status[0] === 'C' ||
900
+ status[1] === 'R' ||
901
+ status[1] === 'C'
902
+ ) {
903
+ i++
904
+ const oldPath = parts[i]
905
+ if (oldPath) {
906
+ files.push(oldPath)
907
+ }
908
+ }
909
+ i++
910
+ }
911
+ return files
912
+ }
913
+
914
+ /**
915
+ * Check if target worktree has dirty files overlapping with the push range.
916
+ * updateInstead only modifies the working tree when pushing to the currently
917
+ * checked-out branch. If the main repo is on a different branch, the push
918
+ * won't touch the working tree at all, so there's nothing to conflict with.
919
+ */
920
+ async function checkTargetWorktreeConflicts({
921
+ targetDir,
922
+ sourceDir,
923
+ targetBranch,
924
+ }: {
925
+ targetDir: string
926
+ sourceDir: string
927
+ targetBranch: string
928
+ }): Promise<string[] | null> {
929
+ // Only check for conflicts if the main repo has the target branch checked out.
930
+ // updateInstead only updates the working tree for the currently checked-out
931
+ // branch — if the main repo is on a different branch, the push to targetBranch
932
+ // won't touch the working tree at all.
933
+ const currentBranch = await git(targetDir, 'symbolic-ref --short HEAD')
934
+ if (currentBranch instanceof Error || currentBranch !== targetBranch) {
935
+ return null
936
+ }
937
+ if (!(await isDirty(targetDir))) {
938
+ return null
939
+ }
940
+ const pushFiles = await getChangedFiles(sourceDir, targetBranch, 'HEAD')
941
+ const dirtyFiles = await getDirtyFiles(targetDir)
942
+ const overlapping = pushFiles.filter((f) => {
943
+ return dirtyFiles.includes(f)
944
+ })
945
+ return overlapping.length > 0 ? overlapping : null
946
+ }
947
+
948
+ /**
949
+ * Check if git is mid-rebase by looking for rebase-merge or rebase-apply dirs.
950
+ */
951
+ async function isRebaseInProgress(dir: string): Promise<boolean> {
952
+ for (const rebaseDir of ['rebase-merge', 'rebase-apply']) {
953
+ const gitPath = await git(dir, `rev-parse --git-path ${rebaseDir}`)
954
+ if (gitPath instanceof Error) {
955
+ continue
956
+ }
957
+ const resolvedPath = path.isAbsolute(gitPath)
958
+ ? gitPath
959
+ : path.resolve(dir, gitPath)
960
+ const exists = await fs.promises
961
+ .access(resolvedPath)
962
+ .then(() => {
963
+ return true
964
+ })
965
+ .catch(() => {
966
+ return false
967
+ })
968
+ if (exists) {
969
+ return true
970
+ }
971
+ }
972
+ return false
973
+ }
974
+
975
+ /**
976
+ * Merge a worktree branch into the default branch by rebasing all commits
977
+ * onto target, then fast-forward pushing. Preserves every worktree commit.
978
+ * Returns MergeWorktreeErrors | MergeSuccess.
979
+ */
980
+ export async function mergeWorktree({
981
+ worktreeDir,
982
+ mainRepoDir,
983
+ worktreeName,
984
+ targetBranch,
985
+ onProgress,
986
+ }: {
987
+ worktreeDir: string
988
+ mainRepoDir: string
989
+ worktreeName: string
990
+ /** Override the branch to merge into. Defaults to origin/HEAD (or main). */
991
+ targetBranch?: string
992
+ onProgress?: (message: string) => void
993
+ }): Promise<MergeWorktreeErrors | MergeSuccess> {
994
+ const log = (msg: string) => {
995
+ logger.log(msg)
996
+ onProgress?.(msg)
997
+ }
998
+
999
+ // Resolve current branch. If detached, create a temp branch.
1000
+ let branchName: string
1001
+ let tempBranch: string | null = null
1002
+ const branchResult = await git(worktreeDir, 'symbolic-ref --short HEAD')
1003
+ if (branchResult instanceof Error) {
1004
+ tempBranch = `kimaki-merge-${Date.now()}`
1005
+ const createResult = await git(worktreeDir, `checkout -b "${tempBranch}"`)
1006
+ if (createResult instanceof Error) {
1007
+ return createResult
1008
+ }
1009
+ branchName = tempBranch
1010
+ } else {
1011
+ branchName = branchResult || worktreeName
1012
+ }
1013
+
1014
+ const defaultBranch = targetBranch || (await getDefaultBranch(mainRepoDir))
1015
+ log(`Merging ${branchName} into ${defaultBranch}`)
1016
+
1017
+ // Best-effort cleanup of temp branch on error paths
1018
+ const cleanupTempBranch = async () => {
1019
+ if (!tempBranch) {
1020
+ return
1021
+ }
1022
+
1023
+ const detachResult = await git(worktreeDir, 'checkout --detach')
1024
+ if (detachResult instanceof Error) {
1025
+ logger.warn(
1026
+ `[MERGE CLEANUP] Failed to detach HEAD before deleting temp branch: ${detachResult.message}`,
1027
+ )
1028
+ }
1029
+
1030
+ const deleteTempBranchResult = await git(
1031
+ worktreeDir,
1032
+ `branch -D "${tempBranch}"`,
1033
+ )
1034
+ if (deleteTempBranchResult instanceof Error) {
1035
+ logger.warn(
1036
+ `[MERGE CLEANUP] Failed to delete temp branch ${tempBranch}: ${deleteTempBranchResult.message}`,
1037
+ )
1038
+ }
1039
+ }
1040
+
1041
+ // ── Step 1: If a rebase is already paused mid-flight, surface it ──
1042
+ // This happens when the user reruns /merge-worktree while the model is
1043
+ // still resolving conflicts. With multi-commit rebases, each conflict
1044
+ // leaves staged conflict markers (isDirty would say yes) AND merge-base
1045
+ // may already equal target (isRebasedOnto would say yes), so neither
1046
+ // of those checks is safe to run first. We must detect the in-progress
1047
+ // rebase explicitly and route back to the AI-resolve flow.
1048
+ if (await isRebaseInProgress(worktreeDir)) {
1049
+ return new RebaseConflictError({ target: defaultBranch })
1050
+ }
1051
+
1052
+ // ── Step 2: Reject uncommitted changes ──
1053
+ if (await isDirty(worktreeDir)) {
1054
+ await cleanupTempBranch()
1055
+ return new DirtyWorktreeError()
1056
+ }
1057
+
1058
+ // ── Step 3: Rebase worktree commits onto target ──
1059
+ // If already rebased onto target AND no rebase is in progress, skip
1060
+ // rebase entirely. The in-progress check above guarantees the second
1061
+ // half; we keep it implicit here.
1062
+ const alreadyRebased = await isRebasedOnto(worktreeDir, defaultBranch)
1063
+
1064
+ const mergeBaseResult = await git(
1065
+ worktreeDir,
1066
+ `merge-base HEAD "${defaultBranch}"`,
1067
+ )
1068
+ const mergeBase =
1069
+ mergeBaseResult instanceof Error ? defaultBranch : mergeBaseResult
1070
+
1071
+ const commitCountResult = await git(
1072
+ worktreeDir,
1073
+ `rev-list --count "${mergeBase}..HEAD"`,
1074
+ )
1075
+ if (commitCountResult instanceof Error) {
1076
+ await cleanupTempBranch()
1077
+ return commitCountResult
1078
+ }
1079
+ const commitCount = parseInt(commitCountResult, 10)
1080
+
1081
+ if (commitCount === 0) {
1082
+ await cleanupTempBranch()
1083
+ return new NothingToMergeError({ target: defaultBranch })
1084
+ }
1085
+
1086
+ if (!alreadyRebased) {
1087
+ // Rebase all worktree commits onto target, preserving each commit.
1088
+ log(
1089
+ commitCount > 1
1090
+ ? `Rebasing ${commitCount} commits onto ${defaultBranch}...`
1091
+ : `Rebasing onto ${defaultBranch}...`,
1092
+ )
1093
+ const rebaseResult = await git(worktreeDir, `rebase "${defaultBranch}"`, {
1094
+ timeout: 60_000,
1095
+ })
1096
+ if (rebaseResult instanceof Error) {
1097
+ if (await isRebaseInProgress(worktreeDir)) {
1098
+ return new RebaseConflictError({
1099
+ target: defaultBranch,
1100
+ cause: rebaseResult,
1101
+ })
1102
+ }
1103
+ await cleanupTempBranch()
1104
+ return new RebaseError({ target: defaultBranch, cause: rebaseResult })
1105
+ }
1106
+ } else {
1107
+ log('Already rebased onto target')
1108
+ }
1109
+
1110
+ // ── Step 4: Fast-forward push via local git push ──
1111
+ if (!(await isAncestor(worktreeDir, defaultBranch, 'HEAD'))) {
1112
+ await cleanupTempBranch()
1113
+ return new NotFastForwardError({ target: defaultBranch })
1114
+ }
1115
+
1116
+ const overlappingFiles = await checkTargetWorktreeConflicts({
1117
+ targetDir: mainRepoDir,
1118
+ sourceDir: worktreeDir,
1119
+ targetBranch: defaultBranch,
1120
+ })
1121
+ if (overlappingFiles) {
1122
+ await cleanupTempBranch()
1123
+ return new ConflictingFilesError({ target: defaultBranch })
1124
+ }
1125
+
1126
+ const gitCommonDir = await getGitCommonDir(worktreeDir)
1127
+ if (gitCommonDir instanceof Error) {
1128
+ await cleanupTempBranch()
1129
+ return gitCommonDir
1130
+ }
1131
+
1132
+ log(`Pushing to ${defaultBranch}...`)
1133
+ const pushResult = await git(
1134
+ worktreeDir,
1135
+ `push --receive-pack="git -c receive.denyCurrentBranch=updateInstead receive-pack" "${gitCommonDir}" "HEAD:${defaultBranch}"`,
1136
+ { timeout: 30_000 },
1137
+ )
1138
+ if (pushResult instanceof Error) {
1139
+ await cleanupTempBranch()
1140
+ return new PushError({ target: defaultBranch, cause: pushResult })
1141
+ }
1142
+
1143
+ // Get short SHA for display
1144
+ const shortSha = await git(worktreeDir, 'rev-parse --short HEAD')
1145
+ if (shortSha instanceof Error) {
1146
+ // Push succeeded but can't get SHA -- non-fatal, use placeholder
1147
+ logger.warn('Failed to get short SHA after push')
1148
+ }
1149
+
1150
+ // ── Step 5: Clean up -- detach HEAD and delete branch ──
1151
+ log('Cleaning up worktree...')
1152
+ const detachResult = await git(worktreeDir, `checkout --detach "${defaultBranch}"`)
1153
+ if (detachResult instanceof Error) {
1154
+ logger.warn(
1155
+ `[MERGE CLEANUP] Failed to detach worktree HEAD after push: ${detachResult.message}`,
1156
+ )
1157
+ }
1158
+
1159
+ const deleteBranchResult = await git(worktreeDir, `branch -D "${branchName}"`)
1160
+ if (deleteBranchResult instanceof Error) {
1161
+ logger.warn(
1162
+ `[MERGE CLEANUP] Failed to delete branch ${branchName}: ${deleteBranchResult.message}`,
1163
+ )
1164
+ }
1165
+
1166
+ if (branchName !== worktreeName && worktreeName) {
1167
+ const deleteWorktreeBranchResult = await git(
1168
+ worktreeDir,
1169
+ `branch -D "${worktreeName}"`,
1170
+ )
1171
+ if (deleteWorktreeBranchResult instanceof Error) {
1172
+ logger.warn(
1173
+ `[MERGE CLEANUP] Failed to delete worktree branch ${worktreeName}: ${deleteWorktreeBranchResult.message}`,
1174
+ )
1175
+ }
1176
+ }
1177
+
1178
+ return {
1179
+ defaultBranch,
1180
+ branchName: worktreeName || branchName,
1181
+ commitCount,
1182
+ shortSha: shortSha instanceof Error ? 'unknown' : shortSha,
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * List branches sorted by most recent commit date.
1188
+ * Returns branch short names (e.g. "main", "origin/feature-x").
1189
+ * Filters by optional query string (case-insensitive substring match).
1190
+ * Limited to 25 results for Discord autocomplete.
1191
+ *
1192
+ * @param includeRemote - When true (default), includes remote tracking branches (`-a` flag).
1193
+ * Set to false for merge targets where only local branches make sense.
1194
+ */
1195
+ export async function listBranchesByLastCommit({
1196
+ directory,
1197
+ query,
1198
+ includeRemote = true,
1199
+ }: {
1200
+ directory: string
1201
+ query?: string
1202
+ includeRemote?: boolean
1203
+ }): Promise<string[]> {
1204
+ const branchFlag = includeRemote ? '-a' : ''
1205
+ const result = await git(
1206
+ directory,
1207
+ `branch ${branchFlag} --sort=-committerdate --format=%(refname:short)`,
1208
+ )
1209
+ if (result instanceof Error) {
1210
+ return []
1211
+ }
1212
+
1213
+ const lowerQuery = query?.toLowerCase() || ''
1214
+ return result
1215
+ .split('\n')
1216
+ .map((line) => {
1217
+ return line.trim()
1218
+ })
1219
+ .filter((name) => {
1220
+ if (!name) {
1221
+ return false
1222
+ }
1223
+ // Skip HEAD pointer entries like "origin/HEAD -> origin/main"
1224
+ if (name.includes('->')) {
1225
+ return false
1226
+ }
1227
+ if (!lowerQuery) {
1228
+ return true
1229
+ }
1230
+ return name.toLowerCase().includes(lowerQuery)
1231
+ })
1232
+ .slice(0, 25)
1233
+ }
1234
+
1235
+ /**
1236
+ * Validate that a branch name is safe for use in git commands.
1237
+ * Uses `git check-ref-format --branch` which rejects names with shell metacharacters,
1238
+ * double dots, trailing dots/locks, etc. Returns the normalized name or an Error.
1239
+ */
1240
+ export async function validateBranchRef({
1241
+ directory,
1242
+ ref,
1243
+ }: {
1244
+ directory: string
1245
+ ref: string
1246
+ }): Promise<string | Error> {
1247
+ const result = await git(directory, `check-ref-format --branch ${JSON.stringify(ref)}`)
1248
+ if (result instanceof Error) {
1249
+ return new Error(`Invalid branch name: ${ref}`)
1250
+ }
1251
+ return result
1252
+ }
1253
+
1254
+ /**
1255
+ * Validate that a directory is a git worktree of the given project.
1256
+ * Parses `git worktree list --porcelain` from the project directory and
1257
+ * checks that the candidate path appears as one of the listed worktrees.
1258
+ * Returns the resolved absolute path on success, or an Error on failure.
1259
+ */
1260
+ export async function validateWorktreeDirectory({
1261
+ projectDirectory,
1262
+ candidatePath,
1263
+ }: {
1264
+ projectDirectory: string
1265
+ candidatePath: string
1266
+ }): Promise<string | Error> {
1267
+ const absoluteCandidate = path.resolve(candidatePath)
1268
+
1269
+ if (!fs.existsSync(absoluteCandidate)) {
1270
+ return new Error(`Directory does not exist: ${absoluteCandidate}`)
1271
+ }
1272
+
1273
+ const result = await git(projectDirectory, 'worktree list --porcelain')
1274
+ if (result instanceof Error) {
1275
+ return new Error('Failed to list git worktrees', { cause: result })
1276
+ }
1277
+
1278
+ const worktreePaths = result
1279
+ .split('\n')
1280
+ .filter((line) => {
1281
+ return line.startsWith('worktree ')
1282
+ })
1283
+ .map((line) => {
1284
+ return line.slice('worktree '.length)
1285
+ })
1286
+
1287
+ if (!worktreePaths.includes(absoluteCandidate)) {
1288
+ return new Error(
1289
+ `Directory is not a git worktree of ${projectDirectory}: ${absoluteCandidate}`,
1290
+ )
1291
+ }
1292
+
1293
+ return absoluteCandidate
1294
+ }