@thevinci/web 1.0.0

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 (337) hide show
  1. package/README.md +197 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +4887 -0
  5. package/bin/cli.test.js +64 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +528 -0
  12. package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
  13. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  14. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  15. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  16. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  17. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  18. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  19. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  20. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  21. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  22. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  23. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  24. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  25. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  26. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  27. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  28. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  31. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  32. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  33. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  34. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  35. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  36. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  37. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  38. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  39. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  40. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  41. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  42. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  43. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  44. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  45. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  49. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  50. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  51. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  52. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  53. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  54. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  55. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  56. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  57. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  58. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  59. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  60. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  61. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  62. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  63. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  64. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  65. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  66. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  67. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  68. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  69. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  70. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  71. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  72. package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
  73. package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
  74. package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
  75. package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
  76. package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
  77. package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
  78. package/dist/assets/es-4o9ciP61.js +15 -0
  79. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  80. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  81. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  82. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  83. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  84. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  85. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  86. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  87. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  88. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  89. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  90. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  91. package/dist/assets/index-DLTDToSP.css +1 -0
  92. package/dist/assets/index-DgiFEKGN.js +1 -0
  93. package/dist/assets/ko-B20imCHE.js +15 -0
  94. package/dist/assets/main-BV3KOtdA.css +1 -0
  95. package/dist/assets/main-CDKJj0sH.js +226 -0
  96. package/dist/assets/main-LC-PSNVM.js +2 -0
  97. package/dist/assets/miniChat-CQUiG_cr.js +2 -0
  98. package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
  99. package/dist/assets/pl-DQJ7LSzj.js +15 -0
  100. package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
  101. package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
  102. package/dist/assets/uk-BNFxOlO4.js +15 -0
  103. package/dist/assets/vendor--DBfsbEis.css +1 -0
  104. package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
  105. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  106. package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
  107. package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
  108. package/dist/assets/worker-CbT6TVo7.js +155 -0
  109. package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
  110. package/dist/favicon-16.png +0 -0
  111. package/dist/favicon-32.png +0 -0
  112. package/dist/favicon.png +0 -0
  113. package/dist/favicon.svg +528 -0
  114. package/dist/index.html +607 -0
  115. package/dist/logo-dark-192x192.png +0 -0
  116. package/dist/logo-dark-512x512.png +0 -0
  117. package/dist/logo-dark-512x512.svg +528 -0
  118. package/dist/logo-light-192x192.png +0 -0
  119. package/dist/logo-light-512x512.png +0 -0
  120. package/dist/logo-light-512x512.svg +528 -0
  121. package/dist/mini-chat.html +16 -0
  122. package/dist/pwa-192.png +0 -0
  123. package/dist/pwa-512.png +0 -0
  124. package/dist/pwa-maskable-192.png +0 -0
  125. package/dist/pwa-maskable-512.png +0 -0
  126. package/dist/site.webmanifest +21 -0
  127. package/dist/sw.js +1 -0
  128. package/package.json +118 -0
  129. package/public/apple-touch-icon-120x120.png +0 -0
  130. package/public/apple-touch-icon-152x152.png +0 -0
  131. package/public/apple-touch-icon-167x167.png +0 -0
  132. package/public/apple-touch-icon-180x180.png +0 -0
  133. package/public/apple-touch-icon.png +0 -0
  134. package/public/apple-touch-icon.svg +528 -0
  135. package/public/favicon-16.png +0 -0
  136. package/public/favicon-32.png +0 -0
  137. package/public/favicon.png +0 -0
  138. package/public/favicon.svg +528 -0
  139. package/public/logo-dark-192x192.png +0 -0
  140. package/public/logo-dark-512x512.png +0 -0
  141. package/public/logo-dark-512x512.svg +528 -0
  142. package/public/logo-light-192x192.png +0 -0
  143. package/public/logo-light-512x512.png +0 -0
  144. package/public/logo-light-512x512.svg +528 -0
  145. package/public/pwa-192.png +0 -0
  146. package/public/pwa-512.png +0 -0
  147. package/public/pwa-maskable-192.png +0 -0
  148. package/public/pwa-maskable-512.png +0 -0
  149. package/public/site.webmanifest +21 -0
  150. package/server/TERMINAL_WS_PROTOCOL.md +48 -0
  151. package/server/index.d.ts +39 -0
  152. package/server/index.js +1311 -0
  153. package/server/lib/cloudflare-tunnel.js +650 -0
  154. package/server/lib/event-stream/DOCUMENTATION.md +61 -0
  155. package/server/lib/event-stream/directory-ws-bridge.js +185 -0
  156. package/server/lib/event-stream/global-hub.js +158 -0
  157. package/server/lib/event-stream/global-hub.test.js +140 -0
  158. package/server/lib/event-stream/global-ws-bridge.js +206 -0
  159. package/server/lib/event-stream/index.js +25 -0
  160. package/server/lib/event-stream/protocol.js +131 -0
  161. package/server/lib/event-stream/protocol.test.js +182 -0
  162. package/server/lib/event-stream/runtime.js +180 -0
  163. package/server/lib/event-stream/runtime.test.js +512 -0
  164. package/server/lib/event-stream/upstream-reader.js +226 -0
  165. package/server/lib/event-stream/upstream-reader.test.js +276 -0
  166. package/server/lib/fs/DOCUMENTATION.md +36 -0
  167. package/server/lib/fs/routes.js +1040 -0
  168. package/server/lib/fs/search.js +238 -0
  169. package/server/lib/git/DOCUMENTATION.md +152 -0
  170. package/server/lib/git/credentials.js +74 -0
  171. package/server/lib/git/identity-storage.js +112 -0
  172. package/server/lib/git/index.js +6 -0
  173. package/server/lib/git/routes.js +972 -0
  174. package/server/lib/git/service.js +3432 -0
  175. package/server/lib/git/service.test.js +39 -0
  176. package/server/lib/github/DOCUMENTATION.md +171 -0
  177. package/server/lib/github/auth.js +307 -0
  178. package/server/lib/github/device-flow.js +50 -0
  179. package/server/lib/github/index.js +24 -0
  180. package/server/lib/github/octokit.js +10 -0
  181. package/server/lib/github/pr-status.js +519 -0
  182. package/server/lib/github/repo/fork-detection.js +102 -0
  183. package/server/lib/github/repo/index.js +55 -0
  184. package/server/lib/github/routes.js +1560 -0
  185. package/server/lib/magic-prompts/routes.js +63 -0
  186. package/server/lib/magic-prompts/runtime.js +119 -0
  187. package/server/lib/notifications/DOCUMENTATION.md +122 -0
  188. package/server/lib/notifications/emitter-runtime.js +102 -0
  189. package/server/lib/notifications/index.js +4 -0
  190. package/server/lib/notifications/message.js +52 -0
  191. package/server/lib/notifications/message.test.js +34 -0
  192. package/server/lib/notifications/push-runtime.js +304 -0
  193. package/server/lib/notifications/routes.js +315 -0
  194. package/server/lib/notifications/runtime.js +566 -0
  195. package/server/lib/notifications/template-runtime.js +349 -0
  196. package/server/lib/notifications/template-runtime.test.js +26 -0
  197. package/server/lib/opencode/DOCUMENTATION.md +362 -0
  198. package/server/lib/opencode/agents.js +634 -0
  199. package/server/lib/opencode/auth-state-runtime.js +88 -0
  200. package/server/lib/opencode/auth.js +83 -0
  201. package/server/lib/opencode/bootstrap-runtime.js +131 -0
  202. package/server/lib/opencode/cli-entry-runtime.js +43 -0
  203. package/server/lib/opencode/cli-options.js +128 -0
  204. package/server/lib/opencode/commands.js +339 -0
  205. package/server/lib/opencode/config-entity-routes.js +370 -0
  206. package/server/lib/opencode/core-routes.js +500 -0
  207. package/server/lib/opencode/core-routes.test.js +26 -0
  208. package/server/lib/opencode/env-config.js +74 -0
  209. package/server/lib/opencode/env-keys.js +68 -0
  210. package/server/lib/opencode/env-runtime.js +1162 -0
  211. package/server/lib/opencode/env-runtime.test.js +116 -0
  212. package/server/lib/opencode/feature-routes-runtime.js +244 -0
  213. package/server/lib/opencode/hmr-state-runtime.js +85 -0
  214. package/server/lib/opencode/index.js +66 -0
  215. package/server/lib/opencode/lifecycle.js +1019 -0
  216. package/server/lib/opencode/lifecycle.test.js +240 -0
  217. package/server/lib/opencode/mcp.js +278 -0
  218. package/server/lib/opencode/network-runtime.js +104 -0
  219. package/server/lib/opencode/network-runtime.test.js +37 -0
  220. package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
  221. package/server/lib/opencode/path-utils.js +100 -0
  222. package/server/lib/opencode/path-utils.test.js +71 -0
  223. package/server/lib/opencode/project-directory-runtime.js +124 -0
  224. package/server/lib/opencode/project-icon-routes.js +399 -0
  225. package/server/lib/opencode/project-icon-routes.test.js +107 -0
  226. package/server/lib/opencode/providers.js +96 -0
  227. package/server/lib/opencode/proxy.js +445 -0
  228. package/server/lib/opencode/pwa-manifest-routes.js +257 -0
  229. package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
  230. package/server/lib/opencode/routes.js +541 -0
  231. package/server/lib/opencode/server-startup-runtime.js +156 -0
  232. package/server/lib/opencode/server-utils-runtime.js +168 -0
  233. package/server/lib/opencode/server-utils-runtime.test.js +135 -0
  234. package/server/lib/opencode/session-runtime.js +356 -0
  235. package/server/lib/opencode/session-runtime.test.js +151 -0
  236. package/server/lib/opencode/settings-helpers.js +770 -0
  237. package/server/lib/opencode/settings-helpers.test.js +109 -0
  238. package/server/lib/opencode/settings-normalization-runtime.js +428 -0
  239. package/server/lib/opencode/settings-runtime.js +826 -0
  240. package/server/lib/opencode/settings-runtime.test.js +85 -0
  241. package/server/lib/opencode/shared.js +615 -0
  242. package/server/lib/opencode/shutdown-runtime.js +139 -0
  243. package/server/lib/opencode/shutdown-runtime.test.js +58 -0
  244. package/server/lib/opencode/skill-routes.js +701 -0
  245. package/server/lib/opencode/skills.js +548 -0
  246. package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
  247. package/server/lib/opencode/static-routes-runtime.js +65 -0
  248. package/server/lib/opencode/theme-runtime.js +167 -0
  249. package/server/lib/opencode/tunnel-auth.js +591 -0
  250. package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
  251. package/server/lib/opencode/vinci-routes.js +76 -0
  252. package/server/lib/opencode/watcher.js +115 -0
  253. package/server/lib/opencode/watcher.test.js +239 -0
  254. package/server/lib/preview/proxy-runtime.js +1333 -0
  255. package/server/lib/preview/proxy-runtime.test.js +144 -0
  256. package/server/lib/projects/project-config.js +567 -0
  257. package/server/lib/projects/project-config.test.js +175 -0
  258. package/server/lib/projects/project-id.js +13 -0
  259. package/server/lib/quota/DOCUMENTATION.md +58 -0
  260. package/server/lib/quota/index.js +25 -0
  261. package/server/lib/quota/providers/claude.js +107 -0
  262. package/server/lib/quota/providers/codex.js +113 -0
  263. package/server/lib/quota/providers/copilot.js +165 -0
  264. package/server/lib/quota/providers/google/api.js +92 -0
  265. package/server/lib/quota/providers/google/auth.js +108 -0
  266. package/server/lib/quota/providers/google/index.js +124 -0
  267. package/server/lib/quota/providers/google/transforms.js +109 -0
  268. package/server/lib/quota/providers/index.js +168 -0
  269. package/server/lib/quota/providers/interface.js +55 -0
  270. package/server/lib/quota/providers/kimi.js +108 -0
  271. package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
  272. package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
  273. package/server/lib/quota/providers/nanogpt.js +124 -0
  274. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  275. package/server/lib/quota/providers/openai.js +91 -0
  276. package/server/lib/quota/providers/openrouter.js +92 -0
  277. package/server/lib/quota/providers/zai.js +91 -0
  278. package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
  279. package/server/lib/quota/providers/zhipuai.js +114 -0
  280. package/server/lib/quota/routes.js +27 -0
  281. package/server/lib/quota/utils/auth.js +50 -0
  282. package/server/lib/quota/utils/formatters.js +85 -0
  283. package/server/lib/quota/utils/formatters.test.js +54 -0
  284. package/server/lib/quota/utils/index.js +10 -0
  285. package/server/lib/quota/utils/transformers.js +55 -0
  286. package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
  287. package/server/lib/scheduled-tasks/routes.js +235 -0
  288. package/server/lib/scheduled-tasks/runtime.js +773 -0
  289. package/server/lib/scheduled-tasks/runtime.test.js +100 -0
  290. package/server/lib/security/request-security.js +115 -0
  291. package/server/lib/session-folders/routes.js +63 -0
  292. package/server/lib/session-folders/routes.test.js +102 -0
  293. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  294. package/server/lib/skills-catalog/cache.js +29 -0
  295. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  296. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  297. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  298. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  299. package/server/lib/skills-catalog/curated-sources.js +21 -0
  300. package/server/lib/skills-catalog/git.js +77 -0
  301. package/server/lib/skills-catalog/index.js +42 -0
  302. package/server/lib/skills-catalog/install.js +294 -0
  303. package/server/lib/skills-catalog/scan.js +221 -0
  304. package/server/lib/skills-catalog/source.js +87 -0
  305. package/server/lib/terminal/DOCUMENTATION.md +76 -0
  306. package/server/lib/terminal/index.js +31 -0
  307. package/server/lib/terminal/output-replay-buffer.js +78 -0
  308. package/server/lib/terminal/output-replay-buffer.test.js +75 -0
  309. package/server/lib/terminal/runtime.js +850 -0
  310. package/server/lib/terminal/runtime.test.js +96 -0
  311. package/server/lib/terminal/terminal-ws-protocol.js +68 -0
  312. package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
  313. package/server/lib/text/DOCUMENTATION.md +35 -0
  314. package/server/lib/text/summarization.js +138 -0
  315. package/server/lib/text/summarization.test.js +34 -0
  316. package/server/lib/tts/DOCUMENTATION.md +146 -0
  317. package/server/lib/tts/base-url.js +62 -0
  318. package/server/lib/tts/capability-runtime.js +31 -0
  319. package/server/lib/tts/index.js +19 -0
  320. package/server/lib/tts/routes.js +261 -0
  321. package/server/lib/tts/routes.test.js +53 -0
  322. package/server/lib/tts/service.js +178 -0
  323. package/server/lib/tts/stt.js +75 -0
  324. package/server/lib/tunnels/DOCUMENTATION.md +18 -0
  325. package/server/lib/tunnels/index.js +166 -0
  326. package/server/lib/tunnels/managed-config.js +201 -0
  327. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  328. package/server/lib/tunnels/registry.js +51 -0
  329. package/server/lib/tunnels/routes.js +605 -0
  330. package/server/lib/tunnels/types.js +219 -0
  331. package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
  332. package/server/lib/ui-auth/ui-auth.js +673 -0
  333. package/server/lib/ui-auth/ui-passkeys.js +545 -0
  334. package/server/opencode-proxy.test.js +151 -0
  335. package/server/proxy-headers.js +61 -0
  336. package/server/proxy-headers.test.js +58 -0
  337. package/server/sse-routes.test.js +152 -0
@@ -0,0 +1,3432 @@
1
+ import simpleGit from 'simple-git';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { createRequire } from 'module';
8
+
9
+ const fsp = fs.promises;
10
+ const require = createRequire(import.meta.url);
11
+ const execFileAsync = promisify(execFile);
12
+ const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
13
+ let resolvedGitBinary = null;
14
+ const worktreeBootstrapState = new Map();
15
+
16
+ const WORKTREE_BOOTSTRAP_PENDING = 'pending';
17
+ const WORKTREE_BOOTSTRAP_READY = 'ready';
18
+ const WORKTREE_BOOTSTRAP_FAILED = 'failed';
19
+
20
+ const toBootstrapStateKey = (directory) => {
21
+ const normalized = normalizeDirectoryPath(directory);
22
+ if (!normalized) {
23
+ return '';
24
+ }
25
+ return path.resolve(normalized);
26
+ };
27
+
28
+ const setWorktreeBootstrapState = (directory, status, error = null) => {
29
+ const key = toBootstrapStateKey(directory);
30
+ if (!key) {
31
+ return;
32
+ }
33
+ worktreeBootstrapState.set(key, {
34
+ status,
35
+ error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
36
+ updatedAt: Date.now(),
37
+ });
38
+ };
39
+
40
+ const clearWorktreeBootstrapState = (directory) => {
41
+ const key = toBootstrapStateKey(directory);
42
+ if (!key) {
43
+ return;
44
+ }
45
+ worktreeBootstrapState.delete(key);
46
+ };
47
+
48
+ const isExecutableFile = (candidate) => {
49
+ if (typeof candidate !== 'string' || candidate.trim().length === 0) {
50
+ return false;
51
+ }
52
+ try {
53
+ const stat = fs.statSync(candidate);
54
+ if (!stat.isFile()) {
55
+ return false;
56
+ }
57
+ if (process.platform === 'win32') {
58
+ const ext = path.extname(candidate).toLowerCase();
59
+ return ext.length === 0 || ext === '.exe' || ext === '.cmd' || ext === '.bat' || ext === '.com';
60
+ }
61
+ fs.accessSync(candidate, fs.constants.X_OK);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
67
+
68
+ const normalizeGitExecutableCandidate = (candidate) => {
69
+ if (typeof candidate !== 'string') {
70
+ return null;
71
+ }
72
+ const trimmed = candidate.trim();
73
+ if (!trimmed) {
74
+ return null;
75
+ }
76
+
77
+ const ext = path.extname(trimmed).toLowerCase();
78
+ if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
79
+ const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
80
+ if (isExecutableFile(exeCandidate)) {
81
+ return exeCandidate;
82
+ }
83
+ }
84
+
85
+ return trimmed;
86
+ };
87
+
88
+ const listPathExecutableCandidates = (binaryName) => {
89
+ const currentPath = process.env.PATH || '';
90
+ const seen = new Set();
91
+ const matches = [];
92
+ for (const segment of currentPath.split(path.delimiter)) {
93
+ const dir = typeof segment === 'string' ? segment.trim() : '';
94
+ if (!dir || seen.has(dir)) {
95
+ continue;
96
+ }
97
+ seen.add(dir);
98
+ matches.push(path.join(dir, binaryName));
99
+ }
100
+ return matches;
101
+ };
102
+
103
+ const listWindowsGitInstallCandidates = () => {
104
+ const roots = [
105
+ process.env.ProgramFiles,
106
+ process.env['ProgramFiles(x86)'],
107
+ process.env.LocalAppData,
108
+ ]
109
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
110
+ .filter(Boolean);
111
+
112
+ const candidates = [];
113
+ for (const root of roots) {
114
+ candidates.push(path.join(root, 'Git', 'cmd', 'git.exe'));
115
+ candidates.push(path.join(root, 'Git', 'bin', 'git.exe'));
116
+ candidates.push(path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'));
117
+ candidates.push(path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'));
118
+ candidates.push(path.join(root, 'Programs', 'Git', 'bin', 'git.exe'));
119
+ }
120
+ return candidates;
121
+ };
122
+
123
+ const resolveGitBinary = () => {
124
+ if (process.platform !== 'win32') {
125
+ return 'git';
126
+ }
127
+ if (resolvedGitBinary) {
128
+ return resolvedGitBinary;
129
+ }
130
+
131
+ const explicit = [process.env.GIT_BINARY, process.env.VINCI_GIT_BINARY]
132
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
133
+ .filter(Boolean);
134
+ for (const candidate of explicit) {
135
+ if (isExecutableFile(candidate)) {
136
+ resolvedGitBinary = candidate;
137
+ return resolvedGitBinary;
138
+ }
139
+ }
140
+
141
+ const discovered = [
142
+ ...listPathExecutableCandidates('git.exe'),
143
+ ...listPathExecutableCandidates('git'),
144
+ ...listWindowsGitInstallCandidates(),
145
+ ]
146
+ .map(normalizeGitExecutableCandidate)
147
+ .filter(Boolean)
148
+ .filter((candidate) => isExecutableFile(candidate));
149
+
150
+ const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
151
+ resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
152
+ return resolvedGitBinary;
153
+ };
154
+
155
+ const getGitBinary = () => resolveGitBinary();
156
+
157
+ /**
158
+ * Escape an SSH key path for use in core.sshCommand.
159
+ * Handles Windows/Unix differences and prevents command injection.
160
+ */
161
+ function escapeSshKeyPath(sshKeyPath) {
162
+ const isWindows = process.platform === 'win32';
163
+
164
+ // Normalize path first on Windows (convert backslashes to forward slashes)
165
+ let normalizedPath = sshKeyPath;
166
+ if (isWindows) {
167
+ normalizedPath = sshKeyPath.replace(/\\/g, '/');
168
+ }
169
+
170
+ // Validate: reject paths with characters that could enable injection
171
+ // Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
172
+ // Note: backslash is not in this list since we've already normalized Windows paths
173
+ const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
174
+ if (dangerousChars.test(normalizedPath)) {
175
+ throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
176
+ }
177
+
178
+ if (isWindows) {
179
+ // On Windows, Git (via MSYS/MinGW) expects Unix-style paths
180
+ // Convert "C:/path" to "/c/path" for MSYS compatibility
181
+ let unixPath = normalizedPath;
182
+ const driveMatch = unixPath.match(/^([A-Za-z]):\//);
183
+ if (driveMatch) {
184
+ unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
185
+ }
186
+
187
+ // Use single quotes for the path (prevents shell interpretation)
188
+ return `'${unixPath}'`;
189
+ } else {
190
+ // On Unix, use single quotes and escape any single quotes in the path
191
+ // Single quotes prevent all shell interpretation except for single quotes themselves
192
+ const escaped = normalizedPath.replace(/'/g, "'\\''");
193
+ return `'${escaped}'`;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Build the SSH command string for git config
199
+ */
200
+ function buildSshCommand(sshKeyPath) {
201
+ const escapedPath = escapeSshKeyPath(sshKeyPath);
202
+ return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
203
+ }
204
+
205
+ const isSocketPath = async (candidate) => {
206
+ if (!candidate || typeof candidate !== 'string') {
207
+ return false;
208
+ }
209
+ try {
210
+ const stat = await fsp.stat(candidate);
211
+ return typeof stat.isSocket === 'function' && stat.isSocket();
212
+ } catch {
213
+ return false;
214
+ }
215
+ };
216
+
217
+ const resolveSshAuthSock = async () => {
218
+ const existing = (process.env.SSH_AUTH_SOCK || '').trim();
219
+ if (existing) {
220
+ return existing;
221
+ }
222
+
223
+ if (process.platform === 'win32') {
224
+ return null;
225
+ }
226
+
227
+ const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
228
+ if (await isSocketPath(gpgSock)) {
229
+ return gpgSock;
230
+ }
231
+
232
+ const runGpgconf = async (args) => {
233
+ for (const candidate of gpgconfCandidates) {
234
+ try {
235
+ const { stdout } = await execFileAsync(candidate, args);
236
+ return String(stdout || '');
237
+ } catch {
238
+ continue;
239
+ }
240
+ }
241
+ return '';
242
+ };
243
+
244
+ const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
245
+ if (candidate && await isSocketPath(candidate)) {
246
+ return candidate;
247
+ }
248
+
249
+ if (candidate) {
250
+ await runGpgconf(['--launch', 'gpg-agent']);
251
+ const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
252
+ if (retried && await isSocketPath(retried)) {
253
+ return retried;
254
+ }
255
+ }
256
+
257
+ return null;
258
+ };
259
+
260
+ const buildGitEnv = async () => {
261
+ const env = { ...process.env };
262
+ if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
263
+ const resolved = await resolveSshAuthSock();
264
+ if (resolved) {
265
+ env.SSH_AUTH_SOCK = resolved;
266
+ }
267
+ }
268
+ return env;
269
+ };
270
+
271
+ const createGit = async (directory) => {
272
+ const env = await buildGitEnv();
273
+ const spawnOptions = { windowsHide: true };
274
+ const binary = getGitBinary();
275
+ const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
276
+ const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
277
+ if (!directory) {
278
+ return simpleGit({ env, spawnOptions, binary, unsafe });
279
+ }
280
+ return simpleGit({
281
+ baseDir: normalizeDirectoryPath(directory),
282
+ env,
283
+ spawnOptions,
284
+ binary,
285
+ unsafe,
286
+ });
287
+ };
288
+
289
+ const normalizeDirectoryPath = (value) => {
290
+ if (typeof value !== 'string') {
291
+ return value;
292
+ }
293
+
294
+ const trimmed = value.trim();
295
+ if (!trimmed) {
296
+ return trimmed;
297
+ }
298
+
299
+ if (trimmed === '~') {
300
+ return os.homedir();
301
+ }
302
+
303
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
304
+ return path.join(os.homedir(), trimmed.slice(2));
305
+ }
306
+
307
+ return trimmed;
308
+ };
309
+
310
+ const cleanBranchName = (branch) => {
311
+ if (!branch) {
312
+ return branch;
313
+ }
314
+ if (branch.startsWith('refs/heads/')) {
315
+ return branch.substring('refs/heads/'.length);
316
+ }
317
+ if (branch.startsWith('heads/')) {
318
+ return branch.substring('heads/'.length);
319
+ }
320
+ if (branch.startsWith('refs/')) {
321
+ return branch.substring('refs/'.length);
322
+ }
323
+ return branch;
324
+ };
325
+
326
+ const OPENCODE_ADJECTIVES = [
327
+ 'brave',
328
+ 'calm',
329
+ 'clever',
330
+ 'cosmic',
331
+ 'crisp',
332
+ 'curious',
333
+ 'eager',
334
+ 'gentle',
335
+ 'glowing',
336
+ 'happy',
337
+ 'hidden',
338
+ 'jolly',
339
+ 'kind',
340
+ 'lucky',
341
+ 'mighty',
342
+ 'misty',
343
+ 'neon',
344
+ 'nimble',
345
+ 'playful',
346
+ 'proud',
347
+ 'quick',
348
+ 'quiet',
349
+ 'shiny',
350
+ 'silent',
351
+ 'stellar',
352
+ 'sunny',
353
+ 'swift',
354
+ 'tidy',
355
+ 'witty',
356
+ ];
357
+
358
+ const OPENCODE_NOUNS = [
359
+ 'cabin',
360
+ 'cactus',
361
+ 'canyon',
362
+ 'circuit',
363
+ 'comet',
364
+ 'eagle',
365
+ 'engine',
366
+ 'falcon',
367
+ 'forest',
368
+ 'garden',
369
+ 'harbor',
370
+ 'island',
371
+ 'knight',
372
+ 'lagoon',
373
+ 'meadow',
374
+ 'moon',
375
+ 'mountain',
376
+ 'nebula',
377
+ 'orchid',
378
+ 'otter',
379
+ 'panda',
380
+ 'pixel',
381
+ 'planet',
382
+ 'river',
383
+ 'rocket',
384
+ 'sailor',
385
+ 'squid',
386
+ 'star',
387
+ 'tiger',
388
+ 'wizard',
389
+ 'wolf',
390
+ ];
391
+
392
+ const OPENCODE_WORKTREE_ATTEMPTS = 26;
393
+
394
+ const getOpenCodeDataPath = () => {
395
+ if (process.env.OPENCODE_DATA_DIR) {
396
+ return path.resolve(process.env.OPENCODE_DATA_DIR);
397
+ }
398
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
399
+ return path.join(xdgDataHome, 'opencode');
400
+ };
401
+
402
+ const pickRandom = (values) => values[Math.floor(Math.random() * values.length)];
403
+
404
+ const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`;
405
+
406
+ const slugWorktreeName = (value) => {
407
+ return String(value || '')
408
+ .trim()
409
+ .replace(/^refs\/heads\//, '')
410
+ .replace(/^heads\//, '')
411
+ .replace(/\s+/g, '-')
412
+ .replace(/^\/+|\/+$/g, '')
413
+ .split('/').join('-')
414
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
415
+ .replace(/-+/g, '-')
416
+ .replace(/^-+/, '')
417
+ .replace(/-+$/, '')
418
+ .slice(0, 80);
419
+ };
420
+
421
+ const parseWorktreePorcelain = (raw) => {
422
+ const lines = String(raw || '').split('\n').map((line) => line.trim());
423
+ const entries = [];
424
+ let current = null;
425
+
426
+ for (const line of lines) {
427
+ if (!line) {
428
+ if (current?.worktree) {
429
+ entries.push(current);
430
+ }
431
+ current = null;
432
+ continue;
433
+ }
434
+
435
+ if (line.startsWith('worktree ')) {
436
+ if (current?.worktree) {
437
+ entries.push(current);
438
+ }
439
+ current = { worktree: line.substring('worktree '.length).trim() };
440
+ continue;
441
+ }
442
+
443
+ if (!current) {
444
+ continue;
445
+ }
446
+
447
+ if (line.startsWith('HEAD ')) {
448
+ current.head = line.substring('HEAD '.length).trim();
449
+ continue;
450
+ }
451
+
452
+ if (line.startsWith('branch ')) {
453
+ const branchRef = line.substring('branch '.length).trim();
454
+ current.branchRef = branchRef;
455
+ current.branch = cleanBranchName(branchRef);
456
+ }
457
+ }
458
+
459
+ if (current?.worktree) {
460
+ entries.push(current);
461
+ }
462
+
463
+ return entries;
464
+ };
465
+
466
+ const canonicalPath = async (input) => {
467
+ const absolutePath = path.resolve(input);
468
+ const realPath = await fsp.realpath(absolutePath).catch(() => absolutePath);
469
+ const normalized = path.normalize(realPath);
470
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
471
+ };
472
+
473
+ const checkPathExists = async (targetPath) => {
474
+ try {
475
+ await fsp.stat(targetPath);
476
+ return true;
477
+ } catch {
478
+ return false;
479
+ }
480
+ };
481
+
482
+ const normalizeStartRef = (value) => {
483
+ const trimmed = String(value || '').trim();
484
+ if (!trimmed) {
485
+ return 'HEAD';
486
+ }
487
+ return trimmed;
488
+ };
489
+
490
+ const parseRemoteBranchRef = (value) => {
491
+ const trimmed = String(value || '').trim();
492
+ if (!trimmed) {
493
+ return null;
494
+ }
495
+
496
+ if (trimmed.startsWith('refs/remotes/')) {
497
+ const rest = trimmed.substring('refs/remotes/'.length);
498
+ const slashIndex = rest.indexOf('/');
499
+ if (slashIndex <= 0 || slashIndex === rest.length - 1) {
500
+ return null;
501
+ }
502
+ return {
503
+ remote: rest.slice(0, slashIndex),
504
+ branch: rest.slice(slashIndex + 1),
505
+ remoteRef: rest,
506
+ fullRef: `refs/remotes/${rest}`,
507
+ };
508
+ }
509
+
510
+ if (trimmed.startsWith('remotes/')) {
511
+ return parseRemoteBranchRef(`refs/${trimmed}`);
512
+ }
513
+
514
+ const slashIndex = trimmed.indexOf('/');
515
+ if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
516
+ return null;
517
+ }
518
+
519
+ return {
520
+ remote: trimmed.slice(0, slashIndex),
521
+ branch: trimmed.slice(slashIndex + 1),
522
+ remoteRef: trimmed,
523
+ fullRef: `refs/remotes/${trimmed}`,
524
+ };
525
+ };
526
+
527
+ const resolveRemoteBranchRef = async (primaryWorktree, value) => {
528
+ const raw = String(value || '').trim();
529
+ const parsed = parseRemoteBranchRef(raw);
530
+ if (!parsed) {
531
+ return null;
532
+ }
533
+
534
+ if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
535
+ return parsed;
536
+ }
537
+
538
+ const localRef = `refs/heads/${raw}`;
539
+ const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
540
+ if (localExists.success) {
541
+ return null;
542
+ }
543
+
544
+ return parsed;
545
+ };
546
+
547
+ const normalizeUpstreamTarget = (remote, branch) => {
548
+ const remoteName = String(remote || '').trim();
549
+ const branchName = String(branch || '').trim();
550
+ if (!remoteName || !branchName) {
551
+ return null;
552
+ }
553
+ return {
554
+ remote: remoteName,
555
+ branch: branchName,
556
+ full: `${remoteName}/${branchName}`,
557
+ };
558
+ };
559
+
560
+ const parseGitErrorText = (error) => {
561
+ const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
562
+ const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
563
+ const message = typeof error?.message === 'string' ? error.message : '';
564
+ return [stderr, stdout, message]
565
+ .map((chunk) => String(chunk || '').trim())
566
+ .filter(Boolean)
567
+ .join('\n')
568
+ .trim();
569
+ };
570
+
571
+ const isNotGitRepositoryError = (error) => {
572
+ const text = parseGitErrorText(error);
573
+ return /not a git repository/i.test(text);
574
+ };
575
+
576
+ const runGitCommand = async (cwd, args) => {
577
+ try {
578
+ const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
579
+ cwd,
580
+ env: await buildGitEnv(),
581
+ windowsHide: true,
582
+ maxBuffer: 20 * 1024 * 1024,
583
+ });
584
+ return {
585
+ success: true,
586
+ exitCode: 0,
587
+ stdout: String(stdout || ''),
588
+ stderr: String(stderr || ''),
589
+ };
590
+ } catch (error) {
591
+ return {
592
+ success: false,
593
+ exitCode: typeof error?.code === 'number' ? error.code : 1,
594
+ stdout: String(error?.stdout || ''),
595
+ stderr: String(error?.stderr || ''),
596
+ message: parseGitErrorText(error),
597
+ };
598
+ }
599
+ };
600
+
601
+ const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
602
+ const result = await runGitCommand(cwd, args);
603
+ if (!result.success) {
604
+ throw new Error(result.message || fallbackMessage || 'Git command failed');
605
+ }
606
+ return result;
607
+ };
608
+
609
+ const ensureOpenCodeProjectId = async (primaryWorktree) => {
610
+ const gitDir = path.join(primaryWorktree, '.git');
611
+ const idFile = path.join(gitDir, 'opencode');
612
+ const existing = await fsp.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => '');
613
+ if (existing) {
614
+ return existing;
615
+ }
616
+
617
+ const rootsResult = await runGitCommandOrThrow(
618
+ primaryWorktree,
619
+ ['rev-list', '--max-parents=0', '--all'],
620
+ 'Failed to resolve repository roots'
621
+ );
622
+
623
+ const roots = rootsResult.stdout
624
+ .split('\n')
625
+ .map((line) => line.trim())
626
+ .filter(Boolean)
627
+ .sort((a, b) => a.localeCompare(b));
628
+
629
+ const projectId = roots[0] || '';
630
+ if (!projectId) {
631
+ throw new Error('Failed to derive OpenCode project ID');
632
+ }
633
+
634
+ await fsp.mkdir(gitDir, { recursive: true }).catch(() => undefined);
635
+ await fsp.writeFile(idFile, projectId, 'utf8').catch(() => undefined);
636
+
637
+ return projectId;
638
+ };
639
+
640
+ const resolveWorktreeProjectContext = async (directory) => {
641
+ const directoryPath = normalizeDirectoryPath(directory);
642
+ if (!directoryPath) {
643
+ throw new Error('Directory is required');
644
+ }
645
+
646
+ const topResult = await runGitCommandOrThrow(
647
+ directoryPath,
648
+ ['rev-parse', '--show-toplevel'],
649
+ 'Failed to resolve git top-level directory'
650
+ );
651
+ const sandbox = path.resolve(directoryPath, topResult.stdout.trim());
652
+
653
+ const commonResult = await runGitCommandOrThrow(
654
+ sandbox,
655
+ ['rev-parse', '--git-common-dir'],
656
+ 'Failed to resolve git common directory'
657
+ );
658
+ const commonDir = path.resolve(sandbox, commonResult.stdout.trim());
659
+ const primaryWorktree = path.dirname(commonDir);
660
+ const projectID = await ensureOpenCodeProjectId(primaryWorktree);
661
+ const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID);
662
+
663
+ return {
664
+ projectID,
665
+ sandbox,
666
+ primaryWorktree,
667
+ worktreeRoot,
668
+ };
669
+ };
670
+
671
+ const listWorktreeEntries = async (directory) => {
672
+ const rawResult = await runGitCommandOrThrow(
673
+ directory,
674
+ ['worktree', 'list', '--porcelain'],
675
+ 'Failed to list git worktrees'
676
+ );
677
+ return parseWorktreePorcelain(rawResult.stdout);
678
+ };
679
+
680
+ const resolveWorktreeNameCandidates = (baseName) => {
681
+ const normalizedBase = slugWorktreeName(baseName || '');
682
+ if (!normalizedBase) {
683
+ return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName());
684
+ }
685
+ return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => {
686
+ if (index === 0) {
687
+ return normalizedBase;
688
+ }
689
+ return `${normalizedBase}-${generateOpenCodeRandomName()}`;
690
+ });
691
+ };
692
+
693
+ const resolveCandidateDirectory = async (worktreeRoot, preferredName, explicitBranchName, primaryWorktree) => {
694
+ const candidates = resolveWorktreeNameCandidates(preferredName);
695
+
696
+ for (const name of candidates) {
697
+ const directory = path.join(worktreeRoot, name);
698
+ if (await checkPathExists(directory)) {
699
+ continue;
700
+ }
701
+
702
+ if (explicitBranchName) {
703
+ return { name, directory, branch: explicitBranchName };
704
+ }
705
+
706
+ const branch = `vinci/${name}`;
707
+ const branchRef = `refs/heads/${branch}`;
708
+ const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]);
709
+ if (branchExists.success) {
710
+ continue;
711
+ }
712
+
713
+ return { name, directory, branch };
714
+ }
715
+
716
+ throw new Error('Failed to generate a unique worktree name');
717
+ };
718
+
719
+ const resolveBranchForExistingMode = async (primaryWorktree, existingBranch, preferredBranchName) => {
720
+ const requested = String(existingBranch || '').trim();
721
+ if (!requested) {
722
+ throw new Error('existingBranch is required in existing mode');
723
+ }
724
+
725
+ const normalizedLocal = cleanBranchName(requested);
726
+ const localRef = `refs/heads/${normalizedLocal}`;
727
+ const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
728
+ if (localExists.success) {
729
+ return {
730
+ localBranch: normalizedLocal,
731
+ checkoutRef: normalizedLocal,
732
+ createLocalBranch: false,
733
+ remoteRef: null,
734
+ };
735
+ }
736
+
737
+ const remoteRef = parseRemoteBranchRef(requested);
738
+ if (!remoteRef) {
739
+ throw new Error(`Branch not found: ${requested}`);
740
+ }
741
+
742
+ const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
743
+ if (!remoteExists.success) {
744
+ await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined);
745
+ const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
746
+ if (!recheck.success) {
747
+ throw new Error(`Remote branch not found: ${requested}`);
748
+ }
749
+ }
750
+
751
+ const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested);
752
+ if (!localBranch) {
753
+ throw new Error('Failed to resolve local branch name for existing branch worktree');
754
+ }
755
+
756
+ return {
757
+ localBranch,
758
+ checkoutRef: remoteRef.remoteRef,
759
+ createLocalBranch: true,
760
+ remoteRef,
761
+ };
762
+ };
763
+
764
+ const findBranchInUse = async (primaryWorktree, localBranchName) => {
765
+ if (!localBranchName) {
766
+ return null;
767
+ }
768
+ const entries = await listWorktreeEntries(primaryWorktree);
769
+ const targetRef = `refs/heads/${localBranchName}`;
770
+ const targetClean = cleanBranchName(targetRef);
771
+ return entries.find((entry) => {
772
+ const entryRef = String(entry.branchRef || '').trim();
773
+ const entryClean = cleanBranchName(entryRef || entry.branch || '');
774
+ return entryRef === targetRef || entryClean === targetClean;
775
+ }) || null;
776
+ };
777
+
778
+ const runWorktreeStartCommand = async (directory, command) => {
779
+ const text = String(command || '').trim();
780
+ if (!text) {
781
+ return { success: true };
782
+ }
783
+
784
+ if (process.platform === 'win32') {
785
+ const result = await execFileAsync('cmd', ['/c', text], {
786
+ cwd: directory,
787
+ env: await buildGitEnv(),
788
+ windowsHide: true,
789
+ maxBuffer: 20 * 1024 * 1024,
790
+ }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
791
+ success: false,
792
+ stdout: error?.stdout,
793
+ stderr: error?.stderr,
794
+ message: parseGitErrorText(error),
795
+ }));
796
+ return result;
797
+ }
798
+
799
+ const result = await execFileAsync('bash', ['-lc', text], {
800
+ cwd: directory,
801
+ env: await buildGitEnv(),
802
+ maxBuffer: 20 * 1024 * 1024,
803
+ }).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
804
+ success: false,
805
+ stdout: error?.stdout,
806
+ stderr: error?.stderr,
807
+ message: parseGitErrorText(error),
808
+ }));
809
+ return result;
810
+ };
811
+
812
+ const loadProjectStartCommand = async (projectID) => {
813
+ const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
814
+ try {
815
+ const raw = await fsp.readFile(storagePath, 'utf8');
816
+ const parsed = JSON.parse(raw);
817
+ const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : '';
818
+ return start || '';
819
+ } catch {
820
+ return '';
821
+ }
822
+ };
823
+
824
+ const getProjectStoragePath = (projectID) => {
825
+ return path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
826
+ };
827
+
828
+ const syncSandboxesToOpenCodeDb = (projectID, sandboxes) => {
829
+ try {
830
+ const Database = require('better-sqlite3');
831
+ const dbPath = path.join(getOpenCodeDataPath(), 'opencode.db');
832
+ if (!fs.existsSync(dbPath)) return;
833
+ const db = new Database(dbPath);
834
+ try {
835
+ const row = db.prepare('SELECT sandboxes FROM project WHERE id = ?').get(projectID);
836
+ if (!row) return;
837
+ const json = JSON.stringify(sandboxes);
838
+ db.prepare('UPDATE project SET sandboxes = ?, time_updated = ? WHERE id = ?').run(json, Date.now(), projectID);
839
+ } finally {
840
+ db.close();
841
+ }
842
+ } catch (error) {
843
+ console.warn('Failed to sync sandboxes to OpenCode DB:', error instanceof Error ? error.message : String(error));
844
+ }
845
+ };
846
+
847
+ const updateProjectSandboxes = async (projectID, primaryWorktree, updater) => {
848
+ const storagePath = getProjectStoragePath(projectID);
849
+ await fsp.mkdir(path.dirname(storagePath), { recursive: true });
850
+
851
+ const now = Date.now();
852
+ const base = {
853
+ id: projectID,
854
+ worktree: primaryWorktree,
855
+ vcs: 'git',
856
+ sandboxes: [],
857
+ time: {
858
+ created: now,
859
+ updated: now,
860
+ },
861
+ };
862
+
863
+ const parsed = await fsp.readFile(storagePath, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
864
+ const current = parsed && typeof parsed === 'object' ? { ...base, ...parsed } : base;
865
+ current.id = String(current.id || projectID);
866
+ current.worktree = String(current.worktree || primaryWorktree);
867
+ current.vcs = current.vcs || 'git';
868
+ current.sandboxes = Array.isArray(current.sandboxes)
869
+ ? current.sandboxes.map((entry) => String(entry || '').trim()).filter(Boolean)
870
+ : [];
871
+ const createdAt = Number(current?.time?.created);
872
+ current.time = {
873
+ created: Number.isFinite(createdAt) && createdAt > 0 ? createdAt : now,
874
+ updated: now,
875
+ };
876
+
877
+ updater(current);
878
+
879
+ current.sandboxes = [...new Set(
880
+ (Array.isArray(current.sandboxes) ? current.sandboxes : [])
881
+ .map((entry) => String(entry || '').trim())
882
+ .filter(Boolean)
883
+ )];
884
+
885
+ await fsp.writeFile(storagePath, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
886
+
887
+ // Sync to OpenCode's SQLite database so project.sandboxes is visible via the SDK
888
+ syncSandboxesToOpenCodeDb(projectID, current.sandboxes);
889
+ };
890
+
891
+ const syncProjectSandboxAdd = async (projectID, primaryWorktree, sandboxPath) => {
892
+ const sandbox = String(sandboxPath || '').trim();
893
+ if (!sandbox) {
894
+ return;
895
+ }
896
+ await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
897
+ if (!project.sandboxes.includes(sandbox)) {
898
+ project.sandboxes.push(sandbox);
899
+ }
900
+ });
901
+ };
902
+
903
+ const syncProjectSandboxRemove = async (projectID, primaryWorktree, sandboxPath) => {
904
+ const sandbox = String(sandboxPath || '').trim();
905
+ if (!sandbox) {
906
+ return;
907
+ }
908
+ await updateProjectSandboxes(projectID, primaryWorktree, (project) => {
909
+ project.sandboxes = project.sandboxes.filter((entry) => entry !== sandbox);
910
+ });
911
+ };
912
+
913
+ const runWorktreeStartScripts = async (directory, projectID, startCommand) => {
914
+ const projectStart = await loadProjectStartCommand(projectID);
915
+ if (projectStart) {
916
+ const projectResult = await runWorktreeStartCommand(directory, projectStart);
917
+ if (!projectResult.success) {
918
+ console.warn('Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
919
+ return;
920
+ }
921
+ }
922
+
923
+ const extraCommand = String(startCommand || '').trim();
924
+ if (!extraCommand) {
925
+ return;
926
+ }
927
+ const extraResult = await runWorktreeStartCommand(directory, extraCommand);
928
+ if (!extraResult.success) {
929
+ console.warn('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
930
+ }
931
+ };
932
+
933
+ const queueWorktreeBootstrap = (args) => {
934
+ const {
935
+ directory,
936
+ projectID,
937
+ primaryWorktree,
938
+ localBranch,
939
+ setUpstream,
940
+ upstreamRemote,
941
+ upstreamBranch,
942
+ ensureRemoteName,
943
+ ensureRemoteUrl,
944
+ startCommand,
945
+ } = args;
946
+ setTimeout(() => {
947
+ const run = async () => {
948
+ await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree');
949
+ if (setUpstream) {
950
+ await applyUpstreamConfiguration({
951
+ primaryWorktree,
952
+ worktreeDirectory: directory,
953
+ localBranch,
954
+ setUpstream,
955
+ upstreamRemote,
956
+ upstreamBranch,
957
+ ensureRemoteName,
958
+ ensureRemoteUrl,
959
+ }).catch((error) => {
960
+ console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
961
+ });
962
+ }
963
+ await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
964
+ console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
965
+ });
966
+ setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
967
+ };
968
+
969
+ void run().catch((error) => {
970
+ setWorktreeBootstrapState(
971
+ directory,
972
+ WORKTREE_BOOTSTRAP_FAILED,
973
+ error instanceof Error ? error.message : String(error)
974
+ );
975
+ console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
976
+ });
977
+ }, 0);
978
+ };
979
+
980
+ const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => {
981
+ const name = String(remoteName || '').trim();
982
+ const url = String(remoteUrl || '').trim();
983
+ if (!name || !url) {
984
+ return;
985
+ }
986
+
987
+ const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]);
988
+ if (getUrl.success) {
989
+ const currentUrl = String(getUrl.stdout || '').trim();
990
+ if (currentUrl !== url) {
991
+ await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL');
992
+ }
993
+ return;
994
+ }
995
+
996
+ await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote');
997
+ };
998
+
999
+ const fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => {
1000
+ const remote = String(remoteName || '').trim();
1001
+ const branch = String(branchName || '').trim();
1002
+ if (!remote || !branch) {
1003
+ return;
1004
+ }
1005
+
1006
+ const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`;
1007
+ await runGitCommandOrThrow(
1008
+ primaryWorktree,
1009
+ ['fetch', remote, refspec],
1010
+ `Failed to fetch ${remote}/${branch}`
1011
+ );
1012
+ };
1013
+
1014
+ const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => {
1015
+ const remote = String(remoteName || '').trim();
1016
+ const branch = String(branchName || '').trim();
1017
+ const url = String(remoteUrl || '').trim();
1018
+ if (!remote || !branch) {
1019
+ return { success: false, found: false };
1020
+ }
1021
+
1022
+ const target = url || remote;
1023
+ const lsRemote = await runGitCommand(
1024
+ primaryWorktree,
1025
+ ['ls-remote', '--heads', target, `refs/heads/${branch}`]
1026
+ );
1027
+ if (!lsRemote.success) {
1028
+ return { success: false, found: false };
1029
+ }
1030
+
1031
+ return {
1032
+ success: true,
1033
+ found: Boolean(String(lsRemote.stdout || '').trim()),
1034
+ };
1035
+ };
1036
+
1037
+ const setBranchTrackingFallback = async (worktreeDirectory, localBranch, upstream) => {
1038
+ await runGitCommandOrThrow(
1039
+ worktreeDirectory,
1040
+ ['config', `branch.${localBranch}.remote`, upstream.remote],
1041
+ `Failed to set branch.${localBranch}.remote`
1042
+ );
1043
+ await runGitCommandOrThrow(
1044
+ worktreeDirectory,
1045
+ ['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`],
1046
+ `Failed to set branch.${localBranch}.merge`
1047
+ );
1048
+ };
1049
+
1050
+ const applyUpstreamConfiguration = async (args) => {
1051
+ const {
1052
+ primaryWorktree,
1053
+ worktreeDirectory,
1054
+ localBranch,
1055
+ setUpstream,
1056
+ upstreamRemote,
1057
+ upstreamBranch,
1058
+ ensureRemoteName,
1059
+ ensureRemoteUrl,
1060
+ } = args;
1061
+
1062
+ if (!setUpstream) {
1063
+ return;
1064
+ }
1065
+
1066
+ if (ensureRemoteName && ensureRemoteUrl) {
1067
+ await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl);
1068
+ }
1069
+
1070
+ const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch);
1071
+ if (!upstream || !localBranch) {
1072
+ return;
1073
+ }
1074
+
1075
+ let fetched = true;
1076
+ try {
1077
+ await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
1078
+ } catch {
1079
+ fetched = false;
1080
+ }
1081
+
1082
+ if (fetched) {
1083
+ await runGitCommandOrThrow(
1084
+ worktreeDirectory,
1085
+ ['branch', `--set-upstream-to=${upstream.full}`, localBranch],
1086
+ `Failed to set upstream to ${upstream.full}`
1087
+ );
1088
+ return;
1089
+ }
1090
+
1091
+ await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream);
1092
+ };
1093
+
1094
+ export async function isGitRepository(directory) {
1095
+ const directoryPath = normalizeDirectoryPath(directory);
1096
+ if (!directoryPath || !fs.existsSync(directoryPath)) {
1097
+ return false;
1098
+ }
1099
+
1100
+ const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']);
1101
+ return result.success;
1102
+ }
1103
+
1104
+ export async function getGlobalIdentity() {
1105
+ const git = await createGit();
1106
+
1107
+ try {
1108
+ const userName = await git.getConfig('user.name', 'global').catch(() => null);
1109
+ const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
1110
+ const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
1111
+
1112
+ return {
1113
+ userName: userName?.value || null,
1114
+ userEmail: userEmail?.value || null,
1115
+ sshCommand: sshCommand?.value || null
1116
+ };
1117
+ } catch (error) {
1118
+ console.error('Failed to get global Git identity:', error);
1119
+ return {
1120
+ userName: null,
1121
+ userEmail: null,
1122
+ sshCommand: null
1123
+ };
1124
+ }
1125
+ }
1126
+
1127
+ export async function getRemoteUrl(directory, remoteName = 'origin') {
1128
+ const git = await createGit(directory);
1129
+
1130
+ try {
1131
+ const url = await git.remote(['get-url', remoteName]);
1132
+ return url?.trim() || null;
1133
+ } catch {
1134
+ return null;
1135
+ }
1136
+ }
1137
+
1138
+ export async function getCurrentIdentity(directory) {
1139
+ const git = await createGit(directory);
1140
+
1141
+ try {
1142
+
1143
+ const userName = await git.getConfig('user.name', 'local').catch(() =>
1144
+ git.getConfig('user.name', 'global')
1145
+ );
1146
+
1147
+ const userEmail = await git.getConfig('user.email', 'local').catch(() =>
1148
+ git.getConfig('user.email', 'global')
1149
+ );
1150
+
1151
+ const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
1152
+ git.getConfig('core.sshCommand', 'global')
1153
+ );
1154
+
1155
+ return {
1156
+ userName: userName?.value || null,
1157
+ userEmail: userEmail?.value || null,
1158
+ sshCommand: sshCommand?.value || null
1159
+ };
1160
+ } catch (error) {
1161
+ console.error('Failed to get current Git identity:', error);
1162
+ return {
1163
+ userName: null,
1164
+ userEmail: null,
1165
+ sshCommand: null
1166
+ };
1167
+ }
1168
+ }
1169
+
1170
+ export async function hasLocalIdentity(directory) {
1171
+ const git = await createGit(directory);
1172
+
1173
+ try {
1174
+ const localName = await git.getConfig('user.name', 'local').catch(() => null);
1175
+ const localEmail = await git.getConfig('user.email', 'local').catch(() => null);
1176
+ return Boolean(localName?.value || localEmail?.value);
1177
+ } catch {
1178
+ return false;
1179
+ }
1180
+ }
1181
+
1182
+ export async function setLocalIdentity(directory, profile) {
1183
+ const git = await createGit(directory);
1184
+
1185
+ try {
1186
+
1187
+ await git.addConfig('user.name', profile.userName, false, 'local');
1188
+ await git.addConfig('user.email', profile.userEmail, false, 'local');
1189
+
1190
+ const authType = profile.authType || 'ssh';
1191
+
1192
+ if (authType === 'ssh' && profile.sshKey) {
1193
+ await git.addConfig(
1194
+ 'core.sshCommand',
1195
+ buildSshCommand(profile.sshKey),
1196
+ false,
1197
+ 'local'
1198
+ );
1199
+ await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
1200
+ } else if (authType === 'token' && profile.host) {
1201
+ await git.addConfig(
1202
+ 'credential.helper',
1203
+ 'store',
1204
+ false,
1205
+ 'local'
1206
+ );
1207
+ await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
1208
+ }
1209
+
1210
+ return true;
1211
+ } catch (error) {
1212
+ console.error('Failed to set Git identity:', error);
1213
+ throw error;
1214
+ }
1215
+ }
1216
+
1217
+ export async function getStatus(directory, options = {}) {
1218
+ const directoryPath = normalizeDirectoryPath(directory);
1219
+ const git = await createGit(directoryPath);
1220
+ const lightMode = options.mode === 'light';
1221
+
1222
+ try {
1223
+ // Use -uall to show all untracked files individually, not just directories
1224
+ const status = await git.status(['-uall']);
1225
+
1226
+ // Light mode: skip numstat + new-file line counting for faster response
1227
+ const [stagedStatsRaw, workingStatsRaw] = lightMode
1228
+ ? ['', '']
1229
+ : await Promise.all([
1230
+ git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
1231
+ git.raw(['diff', '--numstat']).catch(() => ''),
1232
+ ]);
1233
+
1234
+ const diffStatsMap = new Map();
1235
+
1236
+ const accumulateStats = (raw) => {
1237
+ if (!raw) return;
1238
+ raw
1239
+ .split('\n')
1240
+ .map((line) => line.trim())
1241
+ .filter(Boolean)
1242
+ .forEach((line) => {
1243
+ const parts = line.split('\t');
1244
+ if (parts.length < 3) {
1245
+ return;
1246
+ }
1247
+ const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
1248
+ const path = pathParts.join('\t');
1249
+ if (!path) {
1250
+ return;
1251
+ }
1252
+ const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
1253
+ const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
1254
+
1255
+ const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
1256
+ diffStatsMap.set(path, {
1257
+ insertions: existing.insertions + insertions,
1258
+ deletions: existing.deletions + deletions,
1259
+ });
1260
+ });
1261
+ };
1262
+
1263
+ accumulateStats(stagedStatsRaw);
1264
+ accumulateStats(workingStatsRaw);
1265
+
1266
+ const diffStats = Object.fromEntries(diffStatsMap.entries());
1267
+
1268
+ const MAX_NEW_FILE_STATS = 200;
1269
+ const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
1270
+ const newFileStats = [];
1271
+
1272
+ if (!lightMode) {
1273
+ for (const file of status.files) {
1274
+ if (newFileStats.length >= MAX_NEW_FILE_STATS) {
1275
+ break;
1276
+ }
1277
+
1278
+ const working = (file.working_dir || '').trim();
1279
+ const indexStatus = (file.index || '').trim();
1280
+ const statusCode = working || indexStatus;
1281
+
1282
+ if (statusCode !== '?' && statusCode !== 'A') {
1283
+ continue;
1284
+ }
1285
+
1286
+ const existing = diffStats[file.path];
1287
+ if (existing && existing.insertions > 0) {
1288
+ continue;
1289
+ }
1290
+
1291
+ const absolutePath = path.join(directoryPath, file.path);
1292
+
1293
+ try {
1294
+ const stat = await fsp.stat(absolutePath);
1295
+ if (!stat.isFile() || stat.size > MAX_NEW_FILE_STAT_SIZE) {
1296
+ continue;
1297
+ }
1298
+
1299
+ const buffer = await fsp.readFile(absolutePath);
1300
+ if (buffer.indexOf(0) !== -1) {
1301
+ newFileStats.push({
1302
+ path: file.path,
1303
+ insertions: existing?.insertions ?? 0,
1304
+ deletions: existing?.deletions ?? 0,
1305
+ });
1306
+ continue;
1307
+ }
1308
+
1309
+ const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
1310
+ if (!normalized.length) {
1311
+ newFileStats.push({
1312
+ path: file.path,
1313
+ insertions: 0,
1314
+ deletions: 0,
1315
+ });
1316
+ continue;
1317
+ }
1318
+
1319
+ const segments = normalized.split('\n');
1320
+ if (normalized.endsWith('\n')) {
1321
+ segments.pop();
1322
+ }
1323
+
1324
+ const lineCount = segments.length;
1325
+ newFileStats.push({
1326
+ path: file.path,
1327
+ insertions: lineCount,
1328
+ deletions: 0,
1329
+ });
1330
+ } catch (error) {
1331
+ if (error?.code !== 'ENOENT') {
1332
+ console.warn('Failed to estimate diff stats for new file', file.path, error);
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ for (const entry of newFileStats) {
1339
+ diffStats[entry.path] = {
1340
+ insertions: entry.insertions,
1341
+ deletions: entry.deletions,
1342
+ };
1343
+ }
1344
+
1345
+ const selectBaseRefForUnpublished = async () => {
1346
+ const candidates = [];
1347
+
1348
+ const originHead = await git
1349
+ .raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'])
1350
+ .then((value) => String(value || '').trim())
1351
+ .catch(() => '');
1352
+
1353
+ if (originHead) {
1354
+ // "refs/remotes/origin/main" -> "origin/main"
1355
+ candidates.push(originHead.replace(/^refs\/remotes\//, ''));
1356
+ }
1357
+
1358
+ candidates.push('origin/main', 'origin/master', 'main', 'master');
1359
+
1360
+ for (const ref of candidates) {
1361
+ const exists = await git
1362
+ .raw(['rev-parse', '--verify', ref])
1363
+ .then((value) => String(value || '').trim())
1364
+ .catch(() => '');
1365
+ if (exists) return ref;
1366
+ }
1367
+
1368
+ return null;
1369
+ };
1370
+
1371
+ let tracking = status.tracking || null;
1372
+ let ahead = status.ahead;
1373
+ let behind = status.behind;
1374
+
1375
+ // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
1376
+ // We still want to show the number of unpublished commits to the user.
1377
+ // Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
1378
+ if (!lightMode && !tracking && status.current) {
1379
+ const baseRef = await selectBaseRefForUnpublished();
1380
+ if (baseRef) {
1381
+ const countRaw = await git
1382
+ .raw(['rev-list', '--count', `${baseRef}..HEAD`])
1383
+ .then((value) => String(value || '').trim())
1384
+ .catch(() => '');
1385
+ const count = parseInt(countRaw, 10);
1386
+ if (Number.isFinite(count)) {
1387
+ ahead = count;
1388
+ behind = 0;
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Check for in-progress operations
1394
+ let mergeInProgress = null;
1395
+ let rebaseInProgress = null;
1396
+
1397
+ try {
1398
+ // Check MERGE_HEAD for merge in progress
1399
+ const mergeHeadExists = await git
1400
+ .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
1401
+ .then(() => true)
1402
+ .catch(() => false);
1403
+
1404
+ if (mergeHeadExists) {
1405
+ const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
1406
+ const headSha = mergeHead.trim().slice(0, 7);
1407
+ // Only set mergeInProgress if we actually have a valid head SHA
1408
+ if (headSha) {
1409
+ const mergeMsg = await fsp.readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
1410
+ mergeInProgress = {
1411
+ head: headSha,
1412
+ message: mergeMsg.split('\n')[0] || '',
1413
+ };
1414
+ }
1415
+ }
1416
+ } catch {
1417
+ // ignore
1418
+ }
1419
+
1420
+ try {
1421
+ // Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
1422
+ const rebaseMergeExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
1423
+ const rebaseApplyExists = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
1424
+
1425
+ if (rebaseMergeExists || rebaseApplyExists) {
1426
+ const rebaseDir = rebaseMergeExists ? 'rebase-merge' : 'rebase-apply';
1427
+ const headName = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'head-name'), 'utf8').catch(() => '');
1428
+ const onto = await fsp.readFile(path.join(directoryPath, '.git', rebaseDir, 'onto'), 'utf8').catch(() => '');
1429
+
1430
+ const headNameTrimmed = headName.trim().replace('refs/heads/', '');
1431
+ const ontoTrimmed = onto.trim().slice(0, 7);
1432
+
1433
+ // Only set rebaseInProgress if we have valid data
1434
+ if (headNameTrimmed || ontoTrimmed) {
1435
+ rebaseInProgress = {
1436
+ headName: headNameTrimmed,
1437
+ onto: ontoTrimmed,
1438
+ };
1439
+ }
1440
+ }
1441
+ } catch {
1442
+ // ignore
1443
+ }
1444
+
1445
+ return {
1446
+ current: status.current,
1447
+ tracking,
1448
+ ahead,
1449
+ behind,
1450
+ files: status.files.map((f) => ({
1451
+ path: f.path,
1452
+ index: f.index,
1453
+ working_dir: f.working_dir,
1454
+ })),
1455
+ isClean: status.isClean(),
1456
+ diffStats: lightMode ? undefined : diffStats,
1457
+ mergeInProgress,
1458
+ rebaseInProgress,
1459
+ };
1460
+ } catch (error) {
1461
+ if (!isNotGitRepositoryError(error)) {
1462
+ console.error('Failed to get Git status:', error);
1463
+ }
1464
+ throw error;
1465
+ }
1466
+ }
1467
+
1468
+ export async function getDiff(directory, { path, staged = false, contextLines = 3 } = {}) {
1469
+ const git = await createGit(directory);
1470
+
1471
+ try {
1472
+ const args = ['diff', '--no-color'];
1473
+
1474
+ if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
1475
+ args.push(`-U${Math.max(0, contextLines)}`);
1476
+ }
1477
+
1478
+ if (staged) {
1479
+ args.push('--cached');
1480
+ }
1481
+
1482
+ if (path) {
1483
+ args.push('--', path);
1484
+ }
1485
+
1486
+ const diff = await git.raw(args);
1487
+ if (diff && diff.trim().length > 0) {
1488
+ return diff;
1489
+ }
1490
+
1491
+ if (staged) {
1492
+ return diff;
1493
+ }
1494
+
1495
+ try {
1496
+ await git.raw(['ls-files', '--error-unmatch', path]);
1497
+ return diff;
1498
+ } catch {
1499
+ const noIndexArgs = ['diff', '--no-color'];
1500
+ if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
1501
+ noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
1502
+ }
1503
+ noIndexArgs.push('--no-index', '--', '/dev/null', path);
1504
+ try {
1505
+ const noIndexDiff = await git.raw(noIndexArgs);
1506
+ return noIndexDiff;
1507
+ } catch (noIndexError) {
1508
+ // git diff --no-index returns exit code 1 when differences exist (not a real error)
1509
+ if (noIndexError.exitCode === 1 && noIndexError.message) {
1510
+ return noIndexError.message;
1511
+ }
1512
+ throw noIndexError;
1513
+ }
1514
+ }
1515
+ } catch (error) {
1516
+ console.error('Failed to get Git diff:', error);
1517
+ throw error;
1518
+ }
1519
+ }
1520
+
1521
+ export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
1522
+ const git = await createGit(directory);
1523
+ const baseRef = typeof base === 'string' ? base.trim() : '';
1524
+ const headRef = typeof head === 'string' ? head.trim() : '';
1525
+ if (!baseRef || !headRef) {
1526
+ throw new Error('base and head are required');
1527
+ }
1528
+
1529
+ // Prefer remote-tracking base ref so merged commits don't reappear
1530
+ // when local base branch is stale (common when user stays on feature branch).
1531
+ let resolvedBase = baseRef;
1532
+ const originCandidate = `refs/remotes/origin/${baseRef}`;
1533
+ try {
1534
+ const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
1535
+ if (verified && verified.trim()) {
1536
+ resolvedBase = `origin/${baseRef}`;
1537
+ }
1538
+ } catch {
1539
+ // ignore
1540
+ }
1541
+
1542
+ const args = ['diff', '--no-color'];
1543
+ if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
1544
+ args.push(`-U${Math.max(0, contextLines)}`);
1545
+ }
1546
+ args.push(`${resolvedBase}...${headRef}`);
1547
+ if (path) {
1548
+ args.push('--', path);
1549
+ }
1550
+ const diff = await git.raw(args);
1551
+ return diff;
1552
+ }
1553
+
1554
+ export async function getRangeFiles(directory, { base, head } = {}) {
1555
+ const git = await createGit(directory);
1556
+ const baseRef = typeof base === 'string' ? base.trim() : '';
1557
+ const headRef = typeof head === 'string' ? head.trim() : '';
1558
+ if (!baseRef || !headRef) {
1559
+ throw new Error('base and head are required');
1560
+ }
1561
+
1562
+ let resolvedBase = baseRef;
1563
+ const originCandidate = `refs/remotes/origin/${baseRef}`;
1564
+ try {
1565
+ const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
1566
+ if (verified && verified.trim()) {
1567
+ resolvedBase = `origin/${baseRef}`;
1568
+ }
1569
+ } catch {
1570
+ // ignore
1571
+ }
1572
+
1573
+ const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
1574
+ return String(raw || '')
1575
+ .split('\n')
1576
+ .map((l) => l.trim())
1577
+ .filter(Boolean);
1578
+ }
1579
+
1580
+ const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
1581
+
1582
+ const BINARY_SNIFF_BYTES = 8192;
1583
+
1584
+ function isImageFile(filePath) {
1585
+ const ext = filePath.split('.').pop()?.toLowerCase();
1586
+ return IMAGE_EXTENSIONS.includes(ext || '');
1587
+ }
1588
+
1589
+ function getImageMimeType(filePath) {
1590
+ const ext = filePath.split('.').pop()?.toLowerCase();
1591
+ const mimeMap = {
1592
+ 'png': 'image/png',
1593
+ 'jpg': 'image/jpeg',
1594
+ 'jpeg': 'image/jpeg',
1595
+ 'gif': 'image/gif',
1596
+ 'svg': 'image/svg+xml',
1597
+ 'webp': 'image/webp',
1598
+ 'ico': 'image/x-icon',
1599
+ 'bmp': 'image/bmp',
1600
+ 'avif': 'image/avif',
1601
+ };
1602
+ return mimeMap[ext] || 'application/octet-stream';
1603
+ }
1604
+
1605
+ const parseIsBinaryFromNumstat = (raw) => {
1606
+ const text = String(raw || '').trim();
1607
+ if (!text) {
1608
+ return false;
1609
+ }
1610
+
1611
+ // Expected format: <added>\t<deleted>\t<path>
1612
+ const firstLine = text.split('\n').map((line) => line.trim()).find(Boolean) || '';
1613
+ const [added, deleted] = firstLine.split('\t');
1614
+ return added === '-' || deleted === '-';
1615
+ };
1616
+
1617
+ const extractGitStatusPath = (status, pathPart) => {
1618
+ if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
1619
+ return pathPart.split('\t').pop() || pathPart;
1620
+ }
1621
+ return pathPart;
1622
+ };
1623
+
1624
+ const extractGitNumstatDestinationPath = (filePath) => {
1625
+ if (!filePath.includes(' => ')) {
1626
+ return filePath;
1627
+ }
1628
+
1629
+ const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
1630
+ if (braceMatch) {
1631
+ const [, prefix, , destination, suffix] = braceMatch;
1632
+ return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
1633
+ }
1634
+
1635
+ return filePath.split(' => ').pop()?.trim() || filePath;
1636
+ };
1637
+
1638
+ const looksBinaryBySniff = async (absolutePath) => {
1639
+ try {
1640
+ const handle = await fsp.open(absolutePath, 'r');
1641
+ try {
1642
+ const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
1643
+ const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0);
1644
+ if (bytesRead <= 0) {
1645
+ return false;
1646
+ }
1647
+ return buffer.subarray(0, bytesRead).includes(0);
1648
+ } finally {
1649
+ await handle.close();
1650
+ }
1651
+ } catch {
1652
+ return false;
1653
+ }
1654
+ };
1655
+
1656
+ const isBinaryDiff = async (directoryPath, filePath, staged) => {
1657
+ // Fast path: ask git for numstat. For binary, it returns "-\t-\t<path>".
1658
+ const args = ['diff', '--numstat'];
1659
+ if (staged) {
1660
+ args.push('--cached');
1661
+ }
1662
+ args.push('--', filePath);
1663
+
1664
+ const result = await runGitCommand(directoryPath, args);
1665
+ if (parseIsBinaryFromNumstat(result.stdout)) {
1666
+ return true;
1667
+ }
1668
+
1669
+ // Fallback for untracked files (diff output is empty): use --no-index against /dev/null
1670
+ if (!staged) {
1671
+ const tracked = await runGitCommand(directoryPath, ['ls-files', '--error-unmatch', '--', filePath]).then((r) => r.success);
1672
+ if (!tracked) {
1673
+ const noIndex = await runGitCommand(directoryPath, ['diff', '--no-index', '--numstat', '--', '/dev/null', filePath]);
1674
+ if (parseIsBinaryFromNumstat(noIndex.stdout) || parseIsBinaryFromNumstat(noIndex.stderr) || parseIsBinaryFromNumstat(noIndex.message)) {
1675
+ return true;
1676
+ }
1677
+ const text = `${noIndex.stdout || ''}\n${noIndex.stderr || ''}\n${noIndex.message || ''}`.toLowerCase();
1678
+ if (text.includes('binary files') || text.includes('git binary patch')) {
1679
+ return true;
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ return false;
1685
+ };
1686
+
1687
+ export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
1688
+ if (!directory || !filePath) {
1689
+ throw new Error('directory and path are required for getFileDiff');
1690
+ }
1691
+
1692
+ const directoryPath = normalizeDirectoryPath(directory);
1693
+ const git = await createGit(directoryPath);
1694
+ const isImage = isImageFile(filePath);
1695
+ const mimeType = isImage ? getImageMimeType(filePath) : null;
1696
+
1697
+ if (!isImage) {
1698
+ const absolutePath = path.join(directoryPath, filePath);
1699
+ const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
1700
+ const isBinary = isBinaryBySniff || (await isBinaryDiff(directoryPath, filePath, staged));
1701
+ if (isBinary) {
1702
+ return {
1703
+ original: '',
1704
+ modified: '',
1705
+ path: filePath,
1706
+ isBinary: true,
1707
+ };
1708
+ }
1709
+ }
1710
+
1711
+ let original = '';
1712
+ try {
1713
+ if (isImage) {
1714
+ // For images, use git show with raw output and convert to base64
1715
+ try {
1716
+ const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${filePath}`], {
1717
+ cwd: directoryPath,
1718
+ encoding: 'buffer',
1719
+ windowsHide: true,
1720
+ maxBuffer: 50 * 1024 * 1024, // 50MB max
1721
+ });
1722
+ if (stdout && stdout.length > 0) {
1723
+ original = `data:${mimeType};base64,${stdout.toString('base64')}`;
1724
+ }
1725
+ } catch {
1726
+ original = '';
1727
+ }
1728
+ } else {
1729
+ original = await git.show([`HEAD:${filePath}`]);
1730
+ }
1731
+ } catch {
1732
+ original = '';
1733
+ }
1734
+
1735
+ const fullPath = path.join(directoryPath, filePath);
1736
+ let modified = '';
1737
+ try {
1738
+ const stat = await fsp.stat(fullPath);
1739
+ if (stat.isFile()) {
1740
+ if (isImage) {
1741
+ // For images, read as binary and convert to data URL
1742
+ const buffer = await fsp.readFile(fullPath);
1743
+ modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
1744
+ } else {
1745
+ modified = await fsp.readFile(fullPath, 'utf8');
1746
+ }
1747
+ }
1748
+ } catch (error) {
1749
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
1750
+ modified = '';
1751
+ } else {
1752
+ console.error('Failed to read modified file contents for diff:', error);
1753
+ throw error;
1754
+ }
1755
+ }
1756
+
1757
+ return {
1758
+ original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original,
1759
+ modified: typeof modified === 'string' ? modified.replace(/\r\n/g, '\n') : modified,
1760
+ path: filePath,
1761
+ isBinary: false,
1762
+ };
1763
+ }
1764
+
1765
+ export async function revertFile(directory, filePath) {
1766
+ const directoryPath = normalizeDirectoryPath(directory);
1767
+ const git = await createGit(directoryPath);
1768
+ const repoRoot = path.resolve(directoryPath);
1769
+ const absoluteTarget = path.resolve(repoRoot, filePath);
1770
+
1771
+ if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
1772
+ throw new Error('Invalid file path');
1773
+ }
1774
+
1775
+ const isTracked = await git
1776
+ .raw(['ls-files', '--error-unmatch', filePath])
1777
+ .then(() => true)
1778
+ .catch(() => false);
1779
+
1780
+ if (!isTracked) {
1781
+ try {
1782
+ await git.raw(['clean', '-f', '-d', '--', filePath]);
1783
+ return;
1784
+ } catch (cleanError) {
1785
+ try {
1786
+ await fsp.rm(absoluteTarget, { recursive: true, force: true });
1787
+ return;
1788
+ } catch (fsError) {
1789
+ if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
1790
+ return;
1791
+ }
1792
+ console.error('Failed to remove untracked file during revert:', fsError);
1793
+ throw fsError;
1794
+ }
1795
+ }
1796
+ }
1797
+
1798
+ try {
1799
+ await git.raw(['restore', '--staged', filePath]);
1800
+ } catch (error) {
1801
+ await git.raw(['reset', 'HEAD', '--', filePath]).catch(() => {});
1802
+ }
1803
+
1804
+ try {
1805
+ await git.raw(['restore', filePath]);
1806
+ } catch (error) {
1807
+ try {
1808
+ await git.raw(['checkout', '--', filePath]);
1809
+ } catch (fallbackError) {
1810
+ console.error('Failed to revert git file:', fallbackError);
1811
+ throw fallbackError;
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ export async function collectDiffs(directory, files = []) {
1817
+ const results = [];
1818
+ for (const filePath of files) {
1819
+ try {
1820
+ const diff = await getDiff(directory, { path: filePath });
1821
+ if (diff && diff.trim().length > 0) {
1822
+ results.push({ path: filePath, diff });
1823
+ }
1824
+ } catch (error) {
1825
+ console.error(`Failed to diff ${filePath}:`, error);
1826
+ }
1827
+ }
1828
+ return results;
1829
+ }
1830
+
1831
+ export async function pull(directory, options = {}) {
1832
+ const git = await createGit(directory);
1833
+ const pullOptions = options.rebase === true
1834
+ ? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null }
1835
+ : options.options || {};
1836
+
1837
+ try {
1838
+ const result = await git.pull(
1839
+ options.remote || 'origin',
1840
+ options.branch,
1841
+ pullOptions
1842
+ );
1843
+
1844
+ return {
1845
+ success: true,
1846
+ summary: result.summary,
1847
+ files: result.files,
1848
+ insertions: result.insertions,
1849
+ deletions: result.deletions
1850
+ };
1851
+ } catch (error) {
1852
+ console.error('Failed to pull:', error);
1853
+ throw error;
1854
+ }
1855
+ }
1856
+
1857
+ export async function listStashes(directory) {
1858
+ const git = await createGit(directory);
1859
+ const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']);
1860
+ return String(output || '')
1861
+ .split('\n')
1862
+ .map((line) => line.trim())
1863
+ .filter(Boolean)
1864
+ .map((line) => {
1865
+ const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f');
1866
+ return { ref, message, relativeTime, hash };
1867
+ })
1868
+ .filter((entry) => entry.ref);
1869
+ }
1870
+
1871
+ export async function countStashFiles(directory, refs = []) {
1872
+ const git = await createGit(directory);
1873
+ const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean)));
1874
+ const counts = {};
1875
+ const concurrency = 4;
1876
+ let cursor = 0;
1877
+
1878
+ const worker = async () => {
1879
+ while (cursor < uniqueRefs.length) {
1880
+ const ref = uniqueRefs[cursor++];
1881
+ if (!ref) continue;
1882
+ try {
1883
+ const names = await git.raw(['stash', 'show', '--name-only', ref]);
1884
+ counts[ref] = String(names || '').split('\n').map((line) => line.trim()).filter(Boolean).length;
1885
+ } catch {
1886
+ counts[ref] = 0;
1887
+ }
1888
+ }
1889
+ };
1890
+
1891
+ await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker()));
1892
+ return counts;
1893
+ }
1894
+ export async function stashPush(directory, options = {}) {
1895
+ const git = await createGit(directory);
1896
+ const message = typeof options.message === 'string' && options.message.trim()
1897
+ ? options.message.trim()
1898
+ : `Vinci stash ${new Date().toISOString()}`;
1899
+ const output = await git.raw(['stash', 'push', '--include-untracked', '-m', message]);
1900
+ return {
1901
+ success: true,
1902
+ created: !/no local changes/i.test(String(output || '')),
1903
+ message,
1904
+ output: String(output || '').trim(),
1905
+ };
1906
+ }
1907
+
1908
+ export async function stashApply(directory, options = {}) {
1909
+ const git = await createGit(directory);
1910
+ const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
1911
+ await git.raw(['stash', 'apply', ref]);
1912
+ return { success: true, ref };
1913
+ }
1914
+
1915
+ export async function stashDrop(directory, options = {}) {
1916
+ const git = await createGit(directory);
1917
+ const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
1918
+ await git.raw(['stash', 'drop', ref]);
1919
+ return { success: true, ref };
1920
+ }
1921
+
1922
+ export async function stashPop(directory, options = {}) {
1923
+ const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
1924
+ await stashApply(directory, { ref });
1925
+ await stashDrop(directory, { ref });
1926
+ return { success: true, ref };
1927
+ }
1928
+
1929
+ export async function push(directory, options = {}) {
1930
+ const git = await createGit(directory);
1931
+
1932
+ const describePushError = (error) => {
1933
+ const fromNestedGit = error?.git && typeof error.git === 'object'
1934
+ ? [error.git.message, error.git.stderr, error.git.stdout]
1935
+ : [];
1936
+ const candidates = [
1937
+ error?.message,
1938
+ error?.stderr,
1939
+ error?.stdout,
1940
+ ...fromNestedGit,
1941
+ ]
1942
+ .map((value) => String(value || '').trim())
1943
+ .filter(Boolean);
1944
+
1945
+ return candidates[0] || 'Failed to push to remote';
1946
+ };
1947
+
1948
+ const buildUpstreamOptions = (raw) => {
1949
+ if (Array.isArray(raw)) {
1950
+ return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
1951
+ }
1952
+
1953
+ if (raw && typeof raw === 'object') {
1954
+ return { ...raw, '--set-upstream': null };
1955
+ }
1956
+
1957
+ return ['--set-upstream'];
1958
+ };
1959
+
1960
+ const looksLikeMissingUpstream = (error) => {
1961
+ const message = String(error?.message || error?.stderr || '').toLowerCase();
1962
+ return (
1963
+ message.includes('has no upstream') ||
1964
+ message.includes('no upstream') ||
1965
+ message.includes('set-upstream') ||
1966
+ message.includes('set upstream') ||
1967
+ (message.includes('upstream') && message.includes('push') && message.includes('-u'))
1968
+ );
1969
+ };
1970
+
1971
+ const normalizePushResult = (result) => {
1972
+ return {
1973
+ success: true,
1974
+ pushed: result.pushed,
1975
+ repo: result.repo,
1976
+ ref: result.ref,
1977
+ };
1978
+ };
1979
+
1980
+ const remote = String(options.remote || '').trim();
1981
+
1982
+ if (!remote && !options.branch) {
1983
+ try {
1984
+ await git.push();
1985
+ return {
1986
+ success: true,
1987
+ pushed: [],
1988
+ repo: directory,
1989
+ ref: null,
1990
+ };
1991
+ } catch (error) {
1992
+ if (!looksLikeMissingUpstream(error)) {
1993
+ const message = describePushError(error);
1994
+ console.error('Failed to push:', error);
1995
+ throw new Error(message);
1996
+ }
1997
+
1998
+ try {
1999
+ const status = await git.status();
2000
+ const branch = status.current;
2001
+ const remotes = await git.getRemotes(true);
2002
+ const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name;
2003
+ if (!branch || !fallbackRemote) {
2004
+ const message = describePushError(error);
2005
+ throw new Error(message);
2006
+ }
2007
+
2008
+ const result = await git.push(fallbackRemote, branch, buildUpstreamOptions(options.options));
2009
+ return normalizePushResult(result);
2010
+ } catch (fallbackError) {
2011
+ const message = describePushError(fallbackError);
2012
+ console.error('Failed to push (including upstream fallback):', fallbackError);
2013
+ throw new Error(message);
2014
+ }
2015
+ }
2016
+ }
2017
+
2018
+ const remoteName = remote || 'origin';
2019
+
2020
+ // If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
2021
+ // When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
2022
+ if (!options.branch) {
2023
+ try {
2024
+ const status = await git.status();
2025
+ if (status.current && !status.tracking) {
2026
+ const result = await git.push(remoteName, status.current, buildUpstreamOptions(options.options));
2027
+ return normalizePushResult(result);
2028
+ }
2029
+ } catch (error) {
2030
+ // If we can't read status, fall back to the regular push path below.
2031
+ console.warn('Failed to read git status before push:', error);
2032
+ }
2033
+ }
2034
+
2035
+ try {
2036
+ const result = await git.push(remoteName, options.branch, options.options || {});
2037
+ return normalizePushResult(result);
2038
+ } catch (error) {
2039
+ // Last-resort fallback: retry with upstream if the error suggests it's missing.
2040
+ if (!looksLikeMissingUpstream(error)) {
2041
+ const message = describePushError(error);
2042
+ console.error('Failed to push:', error);
2043
+ throw new Error(message);
2044
+ }
2045
+
2046
+ try {
2047
+ const status = await git.status();
2048
+ const branch = options.branch || status.current;
2049
+ if (!branch) {
2050
+ console.error('Failed to push: missing branch name for upstream setup:', error);
2051
+ throw error;
2052
+ }
2053
+
2054
+ const result = await git.push(remoteName, branch, buildUpstreamOptions(options.options));
2055
+ return normalizePushResult(result);
2056
+ } catch (fallbackError) {
2057
+ const message = describePushError(fallbackError);
2058
+ console.error('Failed to push (including upstream fallback):', fallbackError);
2059
+ throw new Error(message);
2060
+ }
2061
+ }
2062
+ }
2063
+
2064
+ export async function deleteRemoteBranch(directory, options = {}) {
2065
+ const { branch, remote } = options;
2066
+ if (!branch) {
2067
+ throw new Error('branch is required to delete remote branch');
2068
+ }
2069
+
2070
+ const git = await createGit(directory);
2071
+ const targetBranch = branch.startsWith('refs/heads/')
2072
+ ? branch.substring('refs/heads/'.length)
2073
+ : branch;
2074
+ const remoteName = remote || 'origin';
2075
+
2076
+ try {
2077
+ await git.push(remoteName, `:${targetBranch}`);
2078
+ return { success: true };
2079
+ } catch (error) {
2080
+ console.error('Failed to delete remote branch:', error);
2081
+ throw error;
2082
+ }
2083
+ }
2084
+
2085
+ export async function fetch(directory, options = {}) {
2086
+ const git = await createGit(directory);
2087
+
2088
+ try {
2089
+ await git.fetch(
2090
+ options.remote || 'origin',
2091
+ options.branch,
2092
+ options.options || {}
2093
+ );
2094
+
2095
+ return { success: true };
2096
+ } catch (error) {
2097
+ console.error('Failed to fetch:', error);
2098
+ throw error;
2099
+ }
2100
+ }
2101
+
2102
+ export async function commit(directory, message, options = {}) {
2103
+ const git = await createGit(directory);
2104
+
2105
+ try {
2106
+ const requestedFiles = Array.isArray(options.files)
2107
+ ? options.files
2108
+ .map((value) => String(value || '').trim())
2109
+ .filter(Boolean)
2110
+ : [];
2111
+ let filesToCommit = requestedFiles;
2112
+
2113
+ if (options.addAll) {
2114
+ await git.add('.');
2115
+ } else if (requestedFiles.length > 0) {
2116
+ const status = await git.status();
2117
+ const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
2118
+ filesToCommit = requestedFiles.filter((filePath) => fileStatusByPath.has(filePath));
2119
+
2120
+ if (filesToCommit.length === 0) {
2121
+ throw new Error('No selected files are available to commit. Refresh git status and try again.');
2122
+ }
2123
+
2124
+ const filesNeedingAdd = filesToCommit.filter((filePath) => {
2125
+ const fileStatus = fileStatusByPath.get(filePath);
2126
+ if (!fileStatus) {
2127
+ return false;
2128
+ }
2129
+
2130
+ const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
2131
+ return !alreadyFullyStaged;
2132
+ });
2133
+
2134
+ if (filesNeedingAdd.length > 0) {
2135
+ await git.add(filesNeedingAdd);
2136
+ }
2137
+ }
2138
+
2139
+ const commitArgs =
2140
+ !options.addAll && filesToCommit.length > 0
2141
+ ? filesToCommit
2142
+ : undefined;
2143
+
2144
+ let result;
2145
+ try {
2146
+ result = await git.commit(message, commitArgs);
2147
+ } catch (error) {
2148
+ const gitErrorText = parseGitErrorText(error);
2149
+ const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
2150
+ if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
2151
+ throw error;
2152
+ }
2153
+
2154
+ // Fallback for deleted/stale selections: commit currently staged changes.
2155
+ result = await git.commit(message);
2156
+ }
2157
+
2158
+ return {
2159
+ success: true,
2160
+ commit: result.commit,
2161
+ branch: result.branch,
2162
+ summary: result.summary
2163
+ };
2164
+ } catch (error) {
2165
+ console.error('Failed to commit:', error);
2166
+ throw error;
2167
+ }
2168
+ }
2169
+
2170
+ export async function getBranches(directory) {
2171
+ const git = await createGit(directory);
2172
+
2173
+ try {
2174
+ const result = await git.branch();
2175
+
2176
+ const allBranches = result.all;
2177
+ const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
2178
+ const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
2179
+
2180
+ const filteredAll = [
2181
+ ...allBranches.filter(branch => !branch.startsWith('remotes/')),
2182
+ ...activeRemoteBranches
2183
+ ];
2184
+
2185
+ return {
2186
+ all: filteredAll,
2187
+ current: result.current,
2188
+ branches: result.branches
2189
+ };
2190
+ } catch (error) {
2191
+ console.error('Failed to get branches:', error);
2192
+ throw error;
2193
+ }
2194
+ }
2195
+
2196
+ async function filterActiveRemoteBranches(git, remoteBranches) {
2197
+ try {
2198
+ const remotes = await git.getRemotes();
2199
+ const branchesByRemote = new Map();
2200
+
2201
+ await Promise.all(remotes.map(async (remote) => {
2202
+ try {
2203
+ const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
2204
+ const actualRemoteBranches = new Set();
2205
+ const lines = lsRemoteResult.trim().split('\n');
2206
+ for (const line of lines) {
2207
+ if (line.includes('\trefs/heads/')) {
2208
+ const branchName = line.split('\t')[1].replace('refs/heads/', '');
2209
+ actualRemoteBranches.add(branchName);
2210
+ }
2211
+ }
2212
+ branchesByRemote.set(remote.name, actualRemoteBranches);
2213
+ } catch {
2214
+ // Skip remotes that fail (e.g., unreachable)
2215
+ }
2216
+ }));
2217
+
2218
+ return remoteBranches.filter(remoteBranch => {
2219
+ const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
2220
+ if (!match) return false;
2221
+ const remoteName = remoteBranch.split('/')[1];
2222
+ const branchName = match[1];
2223
+ return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
2224
+ });
2225
+ } catch (error) {
2226
+ console.warn('Failed to filter active remote branches, returning all:', error.message);
2227
+ return remoteBranches;
2228
+ }
2229
+ }
2230
+
2231
+ export async function createBranch(directory, branchName, options = {}) {
2232
+ const git = await createGit(directory);
2233
+
2234
+ try {
2235
+ await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
2236
+ return { success: true, branch: branchName };
2237
+ } catch (error) {
2238
+ console.error('Failed to create branch:', error);
2239
+ throw error;
2240
+ }
2241
+ }
2242
+
2243
+ export async function checkoutBranch(directory, branchName) {
2244
+ const git = await createGit(directory);
2245
+
2246
+ try {
2247
+ await git.checkout(branchName);
2248
+ return { success: true, branch: branchName };
2249
+ } catch (error) {
2250
+ console.error('Failed to checkout branch:', error);
2251
+ throw error;
2252
+ }
2253
+ }
2254
+
2255
+ export async function getWorktrees(directory) {
2256
+ const directoryPath = normalizeDirectoryPath(directory);
2257
+ if (!directoryPath || !fs.existsSync(directoryPath) || !fs.existsSync(path.join(directoryPath, '.git'))) {
2258
+ return [];
2259
+ }
2260
+ try {
2261
+ const result = await runGitCommandOrThrow(
2262
+ directoryPath,
2263
+ ['worktree', 'list', '--porcelain'],
2264
+ 'Failed to list git worktrees'
2265
+ );
2266
+ return parseWorktreePorcelain(result.stdout).map((entry) => ({
2267
+ head: entry.head || '',
2268
+ name: path.basename(entry.worktree || ''),
2269
+ branch: entry.branch || '',
2270
+ path: entry.worktree,
2271
+ }));
2272
+ } catch (error) {
2273
+ console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
2274
+ return [];
2275
+ }
2276
+ }
2277
+
2278
+ export async function validateWorktreeCreate(directory, input = {}) {
2279
+ const mode = input?.mode === 'existing' ? 'existing' : 'new';
2280
+ const errors = [];
2281
+
2282
+ try {
2283
+ const context = await resolveWorktreeProjectContext(directory);
2284
+ const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
2285
+ const startRef = normalizeStartRef(input?.startRef);
2286
+ const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
2287
+ const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
2288
+
2289
+ let localBranch = '';
2290
+ let inferredUpstream = null;
2291
+
2292
+ if (mode === 'existing') {
2293
+ try {
2294
+ const requestedExistingBranch = String(input?.existingBranch || '').trim();
2295
+ const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
2296
+ if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
2297
+ const lsRemote = await runGitCommand(
2298
+ context.primaryWorktree,
2299
+ ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`]
2300
+ );
2301
+ if (!lsRemote.success) {
2302
+ throw new Error(`Unable to query remote ${ensureRemoteName}`);
2303
+ }
2304
+ if (!String(lsRemote.stdout || '').trim()) {
2305
+ throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`);
2306
+ }
2307
+ localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch);
2308
+ inferredUpstream = {
2309
+ remote: parsedExistingRemote.remote,
2310
+ branch: parsedExistingRemote.branch,
2311
+ };
2312
+ } else {
2313
+ const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
2314
+ localBranch = resolved.localBranch || '';
2315
+ if (resolved.remoteRef) {
2316
+ inferredUpstream = {
2317
+ remote: resolved.remoteRef.remote,
2318
+ branch: resolved.remoteRef.branch,
2319
+ };
2320
+ }
2321
+ }
2322
+ } catch (error) {
2323
+ errors.push({
2324
+ code: 'branch_not_found',
2325
+ message: error instanceof Error ? error.message : 'Existing branch not found',
2326
+ });
2327
+ }
2328
+ } else {
2329
+ if (preferredBranchName) {
2330
+ const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]);
2331
+ if (exists.success) {
2332
+ errors.push({
2333
+ code: 'branch_exists',
2334
+ message: `Branch already exists: ${preferredBranchName}`,
2335
+ });
2336
+ }
2337
+ localBranch = preferredBranchName;
2338
+ }
2339
+
2340
+ const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
2341
+ if (startRef && startRef !== 'HEAD') {
2342
+ if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
2343
+ const remoteCheck = await checkRemoteBranchExists(
2344
+ context.primaryWorktree,
2345
+ parsedRemoteRef.remote,
2346
+ parsedRemoteRef.branch,
2347
+ ensureRemoteUrl
2348
+ );
2349
+ if (!remoteCheck.success) {
2350
+ errors.push({
2351
+ code: 'remote_unreachable',
2352
+ message: `Unable to query remote ${ensureRemoteName}`,
2353
+ });
2354
+ } else if (!remoteCheck.found) {
2355
+ errors.push({
2356
+ code: 'start_ref_not_found',
2357
+ message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
2358
+ });
2359
+ }
2360
+ } else if (parsedRemoteRef) {
2361
+ const remoteCheck = await checkRemoteBranchExists(
2362
+ context.primaryWorktree,
2363
+ parsedRemoteRef.remote,
2364
+ parsedRemoteRef.branch
2365
+ );
2366
+ if (!remoteCheck.success) {
2367
+ errors.push({
2368
+ code: 'remote_unreachable',
2369
+ message: `Unable to query remote ${parsedRemoteRef.remote}`,
2370
+ });
2371
+ } else if (!remoteCheck.found) {
2372
+ errors.push({
2373
+ code: 'start_ref_not_found',
2374
+ message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
2375
+ });
2376
+ }
2377
+ } else {
2378
+ const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]);
2379
+ if (!startRefExists.success) {
2380
+ errors.push({
2381
+ code: 'start_ref_not_found',
2382
+ message: `Start ref not found: ${startRef}`,
2383
+ });
2384
+ }
2385
+ }
2386
+ }
2387
+
2388
+ if (parsedRemoteRef) {
2389
+ inferredUpstream = {
2390
+ remote: parsedRemoteRef.remote,
2391
+ branch: parsedRemoteRef.branch,
2392
+ };
2393
+ }
2394
+ }
2395
+
2396
+ if (localBranch) {
2397
+ const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
2398
+ if (inUse) {
2399
+ errors.push({
2400
+ code: 'branch_in_use',
2401
+ message: `Branch is already checked out in ${inUse.worktree}`,
2402
+ });
2403
+ }
2404
+ }
2405
+
2406
+ if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) {
2407
+ errors.push({
2408
+ code: 'invalid_remote_config',
2409
+ message: 'Both ensureRemoteName and ensureRemoteUrl are required together',
2410
+ });
2411
+ }
2412
+
2413
+ const shouldSetUpstream = Boolean(input?.setUpstream);
2414
+ if (shouldSetUpstream) {
2415
+ const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
2416
+ const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
2417
+
2418
+ if (!upstreamRemote || !upstreamBranch) {
2419
+ errors.push({
2420
+ code: 'upstream_incomplete',
2421
+ message: 'upstreamRemote and upstreamBranch are required when setUpstream is true',
2422
+ });
2423
+ } else {
2424
+ const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]);
2425
+ if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) {
2426
+ errors.push({
2427
+ code: 'remote_not_found',
2428
+ message: `Remote not found: ${upstreamRemote}`,
2429
+ });
2430
+ }
2431
+ }
2432
+ }
2433
+
2434
+ return {
2435
+ ok: errors.length === 0,
2436
+ errors,
2437
+ resolved: {
2438
+ mode,
2439
+ localBranch: localBranch || null,
2440
+ },
2441
+ };
2442
+ } catch (error) {
2443
+ return {
2444
+ ok: false,
2445
+ errors: [{
2446
+ code: 'validation_failed',
2447
+ message: error instanceof Error ? error.message : 'Failed to validate worktree creation',
2448
+ }],
2449
+ };
2450
+ }
2451
+ }
2452
+
2453
+ export async function previewWorktreeCreate(directory, input = {}) {
2454
+ const mode = input?.mode === 'existing' ? 'existing' : 'new';
2455
+ const context = await resolveWorktreeProjectContext(directory);
2456
+ await fsp.mkdir(context.worktreeRoot, { recursive: true });
2457
+
2458
+ const preferredName = String(input?.worktreeName || input?.name || '').trim();
2459
+ const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
2460
+ const candidate = await resolveCandidateDirectory(
2461
+ context.worktreeRoot,
2462
+ preferredName,
2463
+ mode === 'new' && preferredBranchName ? preferredBranchName : '',
2464
+ context.primaryWorktree
2465
+ );
2466
+
2467
+ return {
2468
+ name: candidate.name,
2469
+ branch: mode === 'new' ? candidate.branch : preferredBranchName,
2470
+ path: candidate.directory,
2471
+ };
2472
+ }
2473
+
2474
+ export async function createWorktree(directory, input = {}) {
2475
+ const mode = input?.mode === 'existing' ? 'existing' : 'new';
2476
+ const context = await resolveWorktreeProjectContext(directory);
2477
+ await fsp.mkdir(context.worktreeRoot, { recursive: true });
2478
+
2479
+ const preferredName = String(input?.worktreeName || input?.name || '').trim();
2480
+ const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
2481
+ const startRef = normalizeStartRef(input?.startRef);
2482
+ const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
2483
+ const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
2484
+
2485
+ const candidate = await resolveCandidateDirectory(
2486
+ context.worktreeRoot,
2487
+ preferredName,
2488
+ mode === 'new' && preferredBranchName ? preferredBranchName : '',
2489
+ context.primaryWorktree
2490
+ );
2491
+
2492
+ let localBranch = '';
2493
+ let inferredUpstream = null;
2494
+ const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
2495
+
2496
+ if (mode === 'existing') {
2497
+ const requestedExistingBranch = String(input?.existingBranch || '').trim();
2498
+ const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
2499
+ if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
2500
+ await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
2501
+ await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
2502
+ }
2503
+
2504
+ const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
2505
+ localBranch = resolved.localBranch;
2506
+
2507
+ const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
2508
+ if (inUse) {
2509
+ throw new Error(`Branch is already checked out in ${inUse.worktree}`);
2510
+ }
2511
+
2512
+ if (resolved.createLocalBranch) {
2513
+ worktreeAddArgs.push('-b', localBranch);
2514
+ }
2515
+ worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
2516
+
2517
+ if (resolved.remoteRef) {
2518
+ inferredUpstream = {
2519
+ remote: resolved.remoteRef.remote,
2520
+ branch: resolved.remoteRef.branch,
2521
+ };
2522
+ }
2523
+ } else {
2524
+ localBranch = candidate.branch;
2525
+ if (!localBranch) {
2526
+ throw new Error('Failed to resolve branch name for new worktree');
2527
+ }
2528
+
2529
+ const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]);
2530
+ if (branchExists.success) {
2531
+ throw new Error(`Branch already exists: ${localBranch}`);
2532
+ }
2533
+
2534
+ const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
2535
+ if (inUse) {
2536
+ throw new Error(`Branch is already checked out in ${inUse.worktree}`);
2537
+ }
2538
+
2539
+ worktreeAddArgs.push('-b', localBranch, candidate.directory);
2540
+ if (startRef && startRef !== 'HEAD') {
2541
+ worktreeAddArgs.push(startRef);
2542
+ }
2543
+
2544
+ const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
2545
+ if (parsedRemoteStartRef) {
2546
+ inferredUpstream = {
2547
+ remote: parsedRemoteStartRef.remote,
2548
+ branch: parsedRemoteStartRef.branch,
2549
+ };
2550
+ }
2551
+ }
2552
+
2553
+ if (ensureRemoteName && ensureRemoteUrl) {
2554
+ await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
2555
+ }
2556
+
2557
+ if (mode === 'new') {
2558
+ const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
2559
+ if (parsedRemoteStartRef) {
2560
+ await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
2561
+ }
2562
+ }
2563
+
2564
+ await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
2565
+
2566
+ try {
2567
+ await syncProjectSandboxAdd(context.projectID, context.primaryWorktree, candidate.directory);
2568
+ } catch (error) {
2569
+ console.warn('Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
2570
+ }
2571
+
2572
+ const shouldSetUpstream = Boolean(input?.setUpstream);
2573
+ const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
2574
+ const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
2575
+
2576
+ setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
2577
+
2578
+ queueWorktreeBootstrap({
2579
+ directory: candidate.directory,
2580
+ projectID: context.projectID,
2581
+ primaryWorktree: context.primaryWorktree,
2582
+ localBranch,
2583
+ setUpstream: shouldSetUpstream,
2584
+ upstreamRemote,
2585
+ upstreamBranch,
2586
+ ensureRemoteName,
2587
+ ensureRemoteUrl,
2588
+ startCommand: input?.startCommand,
2589
+ });
2590
+
2591
+ const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']);
2592
+ const head = String(headResult.stdout || '').trim();
2593
+
2594
+ return {
2595
+ head,
2596
+ name: candidate.name,
2597
+ branch: localBranch,
2598
+ path: candidate.directory,
2599
+ };
2600
+ }
2601
+
2602
+ export async function getWorktreeBootstrapStatus(directory) {
2603
+ const key = toBootstrapStateKey(directory);
2604
+ if (!key) {
2605
+ throw new Error('Worktree directory is required');
2606
+ }
2607
+
2608
+ const current = worktreeBootstrapState.get(key);
2609
+ if (current) {
2610
+ return current;
2611
+ }
2612
+
2613
+ return {
2614
+ status: WORKTREE_BOOTSTRAP_READY,
2615
+ error: null,
2616
+ updatedAt: Date.now(),
2617
+ };
2618
+ }
2619
+
2620
+ export async function removeWorktree(directory, input = {}) {
2621
+ const targetDirectory = normalizeDirectoryPath(input?.directory);
2622
+ if (!targetDirectory) {
2623
+ throw new Error('Worktree directory is required');
2624
+ }
2625
+
2626
+ const context = await resolveWorktreeProjectContext(directory);
2627
+ const deleteLocalBranch = input?.deleteLocalBranch === true;
2628
+
2629
+ const targetCanonical = await canonicalPath(targetDirectory);
2630
+ const primaryCanonical = await canonicalPath(context.primaryWorktree);
2631
+ if (targetCanonical === primaryCanonical) {
2632
+ throw new Error('Cannot remove the primary workspace');
2633
+ }
2634
+
2635
+ const entries = await listWorktreeEntries(context.primaryWorktree);
2636
+ const matchedEntry = await (async () => {
2637
+ for (const entry of entries) {
2638
+ if (!entry?.worktree) {
2639
+ continue;
2640
+ }
2641
+ const entryCanonical = await canonicalPath(entry.worktree);
2642
+ if (entryCanonical === targetCanonical) {
2643
+ return entry;
2644
+ }
2645
+ }
2646
+ return null;
2647
+ })();
2648
+
2649
+ if (!matchedEntry?.worktree) {
2650
+ const targetExists = await checkPathExists(targetDirectory);
2651
+ if (targetExists) {
2652
+ await fsp.rm(targetDirectory, { recursive: true, force: true });
2653
+ }
2654
+
2655
+ try {
2656
+ await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, targetDirectory);
2657
+ } catch (error) {
2658
+ console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
2659
+ }
2660
+
2661
+ clearWorktreeBootstrapState(targetDirectory);
2662
+
2663
+ return true;
2664
+ }
2665
+
2666
+ await runGitCommandOrThrow(
2667
+ context.primaryWorktree,
2668
+ ['worktree', 'remove', '--force', matchedEntry.worktree],
2669
+ 'Failed to remove git worktree'
2670
+ );
2671
+
2672
+ if (deleteLocalBranch) {
2673
+ const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
2674
+ if (branchName) {
2675
+ await runGitCommandOrThrow(
2676
+ context.primaryWorktree,
2677
+ ['branch', '-D', branchName],
2678
+ `Failed to delete local branch ${branchName}`
2679
+ );
2680
+ }
2681
+ }
2682
+
2683
+ try {
2684
+ await syncProjectSandboxRemove(context.projectID, context.primaryWorktree, matchedEntry.worktree);
2685
+ } catch (error) {
2686
+ console.warn('Failed to sync OpenCode sandbox metadata (remove):', error instanceof Error ? error.message : String(error));
2687
+ }
2688
+
2689
+ clearWorktreeBootstrapState(matchedEntry.worktree);
2690
+
2691
+ return true;
2692
+ }
2693
+
2694
+ export async function deleteBranch(directory, branch, options = {}) {
2695
+ const git = await createGit(directory);
2696
+
2697
+ try {
2698
+ const branchName = branch.startsWith('refs/heads/')
2699
+ ? branch.substring('refs/heads/'.length)
2700
+ : branch;
2701
+ const args = ['branch', options.force ? '-D' : '-d', branchName];
2702
+ await git.raw(args);
2703
+ return { success: true };
2704
+ } catch (error) {
2705
+ console.error('Failed to delete branch:', error);
2706
+ throw error;
2707
+ }
2708
+ }
2709
+
2710
+ /**
2711
+ * Resolve a log base ref using local-first semantics.
2712
+ *
2713
+ * - If `from` is falsy / whitespace → return undefined.
2714
+ * - If the local ref resolves → return it unchanged (caller's intent preserved).
2715
+ * - If the local ref is absent but `origin/<from>` exists → return `origin/<from>`
2716
+ * (common when the user has never checked out the base branch locally).
2717
+ * - If neither resolves → return `from` unchanged so git surfaces a meaningful error.
2718
+ *
2719
+ * @param {string | undefined} from - The raw `from` option value.
2720
+ * @param {(ref: string) => Promise<boolean>} checkRef - Returns true when the ref resolves.
2721
+ * @returns {Promise<string | undefined>}
2722
+ */
2723
+ export async function resolveBaseRefForLog(from, checkRef) {
2724
+ const normalized = typeof from === 'string' ? from.trim() : undefined;
2725
+ if (!normalized) return undefined;
2726
+
2727
+ if (await checkRef(normalized)) return normalized;
2728
+
2729
+ const originRef = `refs/remotes/origin/${normalized}`;
2730
+ if (await checkRef(originRef)) return `origin/${normalized}`;
2731
+
2732
+ return normalized;
2733
+ }
2734
+
2735
+ export async function getLog(directory, options = {}) {
2736
+ const git = await createGit(directory);
2737
+
2738
+ try {
2739
+ const maxCount = options.maxCount || 50;
2740
+
2741
+ // Prefer the local ref; fall back to origin/<from> only when the local ref
2742
+ // cannot be resolved (e.g. user has never checked out the base branch).
2743
+ const checkRef = async (ref) => {
2744
+ try {
2745
+ const out = await git.raw(['rev-parse', '--verify', ref]);
2746
+ return Boolean(out && out.trim());
2747
+ } catch {
2748
+ return false;
2749
+ }
2750
+ };
2751
+ const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
2752
+
2753
+ const baseLog = await git.log({
2754
+ maxCount,
2755
+ from: resolvedFrom,
2756
+ to: options.to,
2757
+ file: options.file
2758
+ });
2759
+
2760
+ const logArgs = [
2761
+ 'log',
2762
+ `--max-count=${maxCount}`,
2763
+ '--date=iso',
2764
+ '--pretty=format:%H%x1f%an%x1f%ae%x1f%ad%x1f%s%x1e',
2765
+ '--shortstat'
2766
+ ];
2767
+
2768
+ if (resolvedFrom && options.to) {
2769
+ logArgs.push(`${resolvedFrom}..${options.to}`);
2770
+ } else if (resolvedFrom) {
2771
+ logArgs.push(`${resolvedFrom}..HEAD`);
2772
+ } else if (options.to) {
2773
+ logArgs.push(options.to);
2774
+ }
2775
+
2776
+ if (options.file) {
2777
+ logArgs.push('--', options.file);
2778
+ }
2779
+
2780
+ const rawLog = await git.raw(logArgs);
2781
+ const records = rawLog
2782
+ .split('\x1e')
2783
+ .map((entry) => entry.trim())
2784
+ .filter(Boolean);
2785
+
2786
+ const statsMap = new Map();
2787
+
2788
+ records.forEach((record) => {
2789
+ const lines = record.split('\n').filter((line) => line.trim().length > 0);
2790
+ const header = lines.shift() || '';
2791
+ const [hash] = header.split('\x1f');
2792
+ if (!hash) {
2793
+ return;
2794
+ }
2795
+
2796
+ let filesChanged = 0;
2797
+ let insertions = 0;
2798
+ let deletions = 0;
2799
+
2800
+ lines.forEach((line) => {
2801
+ const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
2802
+ const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
2803
+ const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
2804
+
2805
+ if (filesMatch) {
2806
+ filesChanged = parseInt(filesMatch[1], 10);
2807
+ }
2808
+ if (insertMatch) {
2809
+ insertions = parseInt(insertMatch[1], 10);
2810
+ }
2811
+ if (deleteMatch) {
2812
+ deletions = parseInt(deleteMatch[1], 10);
2813
+ }
2814
+ });
2815
+
2816
+ statsMap.set(hash, { filesChanged, insertions, deletions });
2817
+ });
2818
+
2819
+ const merged = baseLog.all.map((entry) => {
2820
+ const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0 };
2821
+ return {
2822
+ hash: entry.hash,
2823
+ date: entry.date,
2824
+ message: entry.message,
2825
+ refs: entry.refs || '',
2826
+ body: entry.body || '',
2827
+ author_name: entry.author_name,
2828
+ author_email: entry.author_email,
2829
+ filesChanged: stats.filesChanged,
2830
+ insertions: stats.insertions,
2831
+ deletions: stats.deletions
2832
+ };
2833
+ });
2834
+
2835
+ return {
2836
+ all: merged,
2837
+ latest: merged[0] || null,
2838
+ total: baseLog.total
2839
+ };
2840
+ } catch (error) {
2841
+ console.error('Failed to get log:', error);
2842
+ throw error;
2843
+ }
2844
+ }
2845
+
2846
+ export async function isLinkedWorktree(directory) {
2847
+ const git = await createGit(directory);
2848
+ try {
2849
+ const [gitDir, gitCommonDir] = await Promise.all([
2850
+ git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
2851
+ git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
2852
+ ]);
2853
+ return gitDir !== gitCommonDir;
2854
+ } catch (error) {
2855
+ console.error('Failed to determine worktree type:', error);
2856
+ return false;
2857
+ }
2858
+ }
2859
+
2860
+ export async function validateWorktreeDirectory(directory, worktreeRoot) {
2861
+ const directoryPath = normalizeDirectoryPath(directory);
2862
+ const rootPath = normalizeDirectoryPath(worktreeRoot);
2863
+
2864
+ if (!directoryPath || !rootPath) {
2865
+ return {
2866
+ valid: false,
2867
+ insideWorktreeRoot: false,
2868
+ resolvedWorktreeRoot: null,
2869
+ resolvedCwd: null,
2870
+ };
2871
+ }
2872
+
2873
+ const isRepo = await isGitRepository(directoryPath);
2874
+ if (!isRepo) {
2875
+ return {
2876
+ valid: false,
2877
+ insideWorktreeRoot: false,
2878
+ resolvedWorktreeRoot: null,
2879
+ resolvedCwd: null,
2880
+ };
2881
+ }
2882
+
2883
+ const resolvedCwd = await canonicalPath(directoryPath);
2884
+ const resolvedRoot = await canonicalPath(rootPath);
2885
+
2886
+ const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot;
2887
+
2888
+ return {
2889
+ valid: true,
2890
+ insideWorktreeRoot: inside,
2891
+ resolvedWorktreeRoot: resolvedRoot,
2892
+ resolvedCwd,
2893
+ };
2894
+ }
2895
+
2896
+ export async function canonicalizeWorktreeState(directory) {
2897
+ const directoryPath = normalizeDirectoryPath(directory);
2898
+
2899
+ if (!directoryPath) {
2900
+ return {
2901
+ worktreeRoot: null,
2902
+ cwd: null,
2903
+ branch: null,
2904
+ headState: 'detached',
2905
+ worktreeStatus: 'not-a-repo',
2906
+ legacy: false,
2907
+ degraded: false,
2908
+ attentionReason: null,
2909
+ };
2910
+ }
2911
+
2912
+ const isRepo = await isGitRepository(directoryPath);
2913
+ if (!isRepo) {
2914
+ return {
2915
+ worktreeRoot: null,
2916
+ cwd: null,
2917
+ branch: null,
2918
+ headState: 'detached',
2919
+ worktreeStatus: 'not-a-repo',
2920
+ legacy: false,
2921
+ degraded: false,
2922
+ attentionReason: null,
2923
+ };
2924
+ }
2925
+
2926
+ const cwd = await canonicalPath(directoryPath);
2927
+ const git = await createGit(directoryPath);
2928
+
2929
+ let worktreeRoot = null;
2930
+ let worktreeStatus = 'ready';
2931
+ let headState = /** @type {'branch' | 'detached' | 'unborn'} */ ('branch');
2932
+ let branch = null;
2933
+ let attentionReason = /** @type {'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null} */ (null);
2934
+
2935
+ try {
2936
+ const context = await resolveWorktreeProjectContext(directoryPath);
2937
+ worktreeRoot = await canonicalPath(context.worktreeRoot);
2938
+ } catch {
2939
+ worktreeStatus = 'invalid';
2940
+ }
2941
+
2942
+ try {
2943
+ const symbolicRef = await git.raw(['symbolic-ref', '-q', 'HEAD']).catch(() => '');
2944
+ if (symbolicRef.trim()) {
2945
+ headState = 'branch';
2946
+ branch = cleanBranchName(symbolicRef.trim());
2947
+ } else {
2948
+ const revParse = await git.raw(['rev-parse', 'HEAD']).catch(() => '');
2949
+ if (!revParse.trim()) {
2950
+ headState = 'unborn';
2951
+ branch = null;
2952
+ } else {
2953
+ headState = 'detached';
2954
+ branch = revParse.trim().slice(0, 7);
2955
+ }
2956
+ }
2957
+ } catch {
2958
+ headState = 'unborn';
2959
+ branch = null;
2960
+ }
2961
+
2962
+ // Detect attention reasons from getStatus side-effects
2963
+ try {
2964
+ const status = await git.status(['-uall']);
2965
+ if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) {
2966
+ attentionReason = 'merge';
2967
+ } else {
2968
+ const rebaseMerge = await fsp.stat(path.join(directoryPath, '.git', 'rebase-merge')).then(() => true).catch(() => false);
2969
+ const rebaseApply = await fsp.stat(path.join(directoryPath, '.git', 'rebase-apply')).then(() => true).catch(() => false);
2970
+ if (rebaseMerge || rebaseApply) {
2971
+ attentionReason = 'rebase';
2972
+ } else if (status.conflicted && status.conflicted.length > 0) {
2973
+ const cherryPickHead = await fsp.stat(path.join(directoryPath, '.git', 'CHERRY_PICK_HEAD')).then(() => true).catch(() => false);
2974
+ const revertHead = await fsp.stat(path.join(directoryPath, '.git', 'REVERT_HEAD')).then(() => true).catch(() => false);
2975
+ if (cherryPickHead) attentionReason = 'cherry-pick';
2976
+ else if (revertHead) attentionReason = 'revert';
2977
+ }
2978
+ }
2979
+ } catch {
2980
+ // Status check failed — ignore
2981
+ }
2982
+
2983
+ return {
2984
+ worktreeRoot,
2985
+ cwd,
2986
+ branch,
2987
+ headState,
2988
+ worktreeStatus,
2989
+ legacy: false,
2990
+ degraded: false,
2991
+ attentionReason,
2992
+ };
2993
+ }
2994
+
2995
+ export async function getCommitFiles(directory, commitHash) {
2996
+ const git = await createGit(directory);
2997
+
2998
+ try {
2999
+
3000
+ const numstatRaw = await git.raw([
3001
+ 'show',
3002
+ '--numstat',
3003
+ '--format=',
3004
+ commitHash
3005
+ ]);
3006
+
3007
+ const files = [];
3008
+ const lines = numstatRaw.trim().split('\n').filter(Boolean);
3009
+
3010
+ for (const line of lines) {
3011
+ const parts = line.split('\t');
3012
+ if (parts.length < 3) continue;
3013
+
3014
+ const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
3015
+ const filePath = pathParts.join('\t');
3016
+ if (!filePath) continue;
3017
+
3018
+ const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
3019
+ const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
3020
+ const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
3021
+
3022
+ let changeType = 'M';
3023
+ let displayPath = filePath;
3024
+
3025
+ if (filePath.includes(' => ')) {
3026
+ changeType = 'R';
3027
+
3028
+ const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
3029
+ if (match) {
3030
+ displayPath = filePath;
3031
+ }
3032
+ }
3033
+
3034
+ files.push({
3035
+ path: displayPath,
3036
+ insertions,
3037
+ deletions,
3038
+ isBinary,
3039
+ changeType
3040
+ });
3041
+ }
3042
+
3043
+ const nameStatusRaw = await git.raw([
3044
+ 'show',
3045
+ '--name-status',
3046
+ '--format=',
3047
+ commitHash
3048
+ ]).catch(() => '');
3049
+
3050
+ const statusMap = new Map();
3051
+ const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
3052
+ for (const line of statusLines) {
3053
+ const match = line.match(/^([AMDRC])\d*\t(.+)$/);
3054
+ if (match) {
3055
+ const [, status, pathPart] = match;
3056
+ statusMap.set(extractGitStatusPath(status, pathPart), status);
3057
+ }
3058
+ }
3059
+
3060
+ for (const file of files) {
3061
+ const basePath = extractGitNumstatDestinationPath(file.path);
3062
+
3063
+ const status = statusMap.get(basePath) || statusMap.get(file.path);
3064
+ if (status) {
3065
+ file.changeType = status;
3066
+ }
3067
+ }
3068
+
3069
+ return { files };
3070
+ } catch (error) {
3071
+ console.error('Failed to get commit files:', error);
3072
+ throw error;
3073
+ }
3074
+ }
3075
+
3076
+ export async function renameBranch(directory, oldName, newName) {
3077
+ const git = await createGit(directory);
3078
+
3079
+ try {
3080
+ const normalizedOldName = cleanBranchName(String(oldName || '').trim());
3081
+ const normalizedNewName = cleanBranchName(String(newName || '').trim());
3082
+
3083
+ const previousRemote = await git
3084
+ .raw(['config', '--get', `branch.${normalizedOldName}.remote`])
3085
+ .then((value) => String(value || '').trim())
3086
+ .catch(() => '');
3087
+ const previousMerge = await git
3088
+ .raw(['config', '--get', `branch.${normalizedOldName}.merge`])
3089
+ .then((value) => String(value || '').trim())
3090
+ .catch(() => '');
3091
+
3092
+ // Use git branch -m command to rename the branch
3093
+ await git.raw(['branch', '-m', oldName, newName]);
3094
+
3095
+ if (previousRemote && previousMerge && normalizedNewName) {
3096
+ const previousMergeBranch = cleanBranchName(previousMerge);
3097
+ const nextMergeBranch =
3098
+ previousMergeBranch === normalizedOldName
3099
+ ? normalizedNewName
3100
+ : previousMergeBranch;
3101
+ const upstream = normalizeUpstreamTarget(previousRemote, nextMergeBranch);
3102
+
3103
+ if (upstream) {
3104
+ try {
3105
+ await runGitCommandOrThrow(
3106
+ directory,
3107
+ ['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName],
3108
+ `Failed to set upstream to ${upstream.full}`
3109
+ );
3110
+ } catch {
3111
+ await setBranchTrackingFallback(directory, normalizedNewName, upstream);
3112
+ }
3113
+ }
3114
+ }
3115
+
3116
+ return { success: true, branch: newName };
3117
+ } catch (error) {
3118
+ console.error('Failed to rename branch:', error);
3119
+ throw error;
3120
+ }
3121
+ }
3122
+
3123
+ export async function getRemotes(directory) {
3124
+ const git = await createGit(directory);
3125
+
3126
+ try {
3127
+ const remotes = await git.getRemotes(true);
3128
+
3129
+ return remotes.map((remote) => ({
3130
+ name: remote.name,
3131
+ fetchUrl: remote.refs.fetch,
3132
+ pushUrl: remote.refs.push
3133
+ }));
3134
+ } catch (error) {
3135
+ if (isNotGitRepositoryError(error)) {
3136
+ return [];
3137
+ }
3138
+ console.error('Failed to get remotes:', error);
3139
+ throw error;
3140
+ }
3141
+ }
3142
+
3143
+ export async function removeRemote(directory, options = {}) {
3144
+ const remoteName = String(options.remote || '').trim();
3145
+ if (!remoteName) {
3146
+ throw new Error('remote is required to remove a remote');
3147
+ }
3148
+ if (remoteName === 'origin') {
3149
+ throw new Error('Cannot remove origin remote');
3150
+ }
3151
+
3152
+ const git = await createGit(directory);
3153
+
3154
+ try {
3155
+ await git.removeRemote(remoteName);
3156
+ return { success: true };
3157
+ } catch (error) {
3158
+ console.error('Failed to remove remote:', error);
3159
+ throw error;
3160
+ }
3161
+ }
3162
+
3163
+ export async function rebase(directory, options = {}) {
3164
+ const git = await createGit(directory);
3165
+
3166
+ try {
3167
+ const { onto } = options;
3168
+ if (!onto) {
3169
+ throw new Error('onto parameter is required for rebase');
3170
+ }
3171
+
3172
+ await git.rebase([onto]);
3173
+
3174
+ return {
3175
+ success: true,
3176
+ conflict: false
3177
+ };
3178
+ } catch (error) {
3179
+ const errorMessage = String(error?.message || error || '').toLowerCase();
3180
+ const isConflict = errorMessage.includes('conflict') ||
3181
+ errorMessage.includes('could not apply') ||
3182
+ errorMessage.includes('merge conflict');
3183
+
3184
+ if (isConflict) {
3185
+ // Get list of conflicted files
3186
+ const status = await git.status().catch(() => ({ conflicted: [] }));
3187
+ return {
3188
+ success: false,
3189
+ conflict: true,
3190
+ conflictFiles: status.conflicted || []
3191
+ };
3192
+ }
3193
+
3194
+ console.error('Failed to rebase:', error);
3195
+ throw error;
3196
+ }
3197
+ }
3198
+
3199
+ export async function abortRebase(directory) {
3200
+ const git = await createGit(directory);
3201
+
3202
+ try {
3203
+ await git.rebase(['--abort']);
3204
+ return { success: true };
3205
+ } catch (error) {
3206
+ console.error('Failed to abort rebase:', error);
3207
+ throw error;
3208
+ }
3209
+ }
3210
+
3211
+ export async function merge(directory, options = {}) {
3212
+ const git = await createGit(directory);
3213
+
3214
+ try {
3215
+ const { branch } = options;
3216
+ if (!branch) {
3217
+ throw new Error('branch parameter is required for merge');
3218
+ }
3219
+
3220
+ await git.merge([branch]);
3221
+
3222
+ return {
3223
+ success: true,
3224
+ conflict: false
3225
+ };
3226
+ } catch (error) {
3227
+ const errorMessage = String(error?.message || error || '').toLowerCase();
3228
+ const isConflict = errorMessage.includes('conflict') ||
3229
+ errorMessage.includes('merge conflict') ||
3230
+ errorMessage.includes('automatic merge failed');
3231
+
3232
+ if (isConflict) {
3233
+ // Get list of conflicted files
3234
+ const status = await git.status().catch(() => ({ conflicted: [] }));
3235
+ return {
3236
+ success: false,
3237
+ conflict: true,
3238
+ conflictFiles: status.conflicted || []
3239
+ };
3240
+ }
3241
+
3242
+ console.error('Failed to merge:', error);
3243
+ throw error;
3244
+ }
3245
+ }
3246
+
3247
+ export async function abortMerge(directory) {
3248
+ const git = await createGit(directory);
3249
+
3250
+ try {
3251
+ await git.merge(['--abort']);
3252
+ return { success: true };
3253
+ } catch (error) {
3254
+ console.error('Failed to abort merge:', error);
3255
+ throw error;
3256
+ }
3257
+ }
3258
+
3259
+ export async function continueRebase(directory) {
3260
+ const directoryPath = normalizeDirectoryPath(directory);
3261
+ const git = await createGit(directoryPath);
3262
+
3263
+ try {
3264
+ // Set GIT_EDITOR to prevent editor prompts
3265
+ await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
3266
+ return { success: true, conflict: false };
3267
+ } catch (error) {
3268
+ const errorMessage = String(error?.message || error || '').toLowerCase();
3269
+ const isConflict = errorMessage.includes('conflict') ||
3270
+ errorMessage.includes('needs merge') ||
3271
+ errorMessage.includes('unmerged') ||
3272
+ errorMessage.includes('fix conflicts');
3273
+
3274
+ if (isConflict) {
3275
+ const status = await git.status().catch(() => ({ conflicted: [] }));
3276
+ return {
3277
+ success: false,
3278
+ conflict: true,
3279
+ conflictFiles: status.conflicted || []
3280
+ };
3281
+ }
3282
+
3283
+ // Check for "nothing to commit" which means rebase step is complete
3284
+ if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
3285
+ // Skip this commit and continue
3286
+ try {
3287
+ await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
3288
+ return { success: true, conflict: false };
3289
+ } catch {
3290
+ // If skip also fails, the rebase may be complete
3291
+ return { success: true, conflict: false };
3292
+ }
3293
+ }
3294
+
3295
+ console.error('Failed to continue rebase:', error);
3296
+ throw error;
3297
+ }
3298
+ }
3299
+
3300
+ export async function continueMerge(directory) {
3301
+ const directoryPath = normalizeDirectoryPath(directory);
3302
+ const git = await createGit(directoryPath);
3303
+
3304
+ try {
3305
+ // Check if there are still unmerged files
3306
+ const status = await git.status();
3307
+ if (status.conflicted && status.conflicted.length > 0) {
3308
+ return {
3309
+ success: false,
3310
+ conflict: true,
3311
+ conflictFiles: status.conflicted
3312
+ };
3313
+ }
3314
+
3315
+ // For merge, we commit after resolving conflicts
3316
+ // Use --no-edit to use the default merge commit message
3317
+ await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
3318
+ return { success: true, conflict: false };
3319
+ } catch (error) {
3320
+ const errorMessage = String(error?.message || error || '').toLowerCase();
3321
+ const isConflict = errorMessage.includes('conflict') ||
3322
+ errorMessage.includes('needs merge') ||
3323
+ errorMessage.includes('unmerged') ||
3324
+ errorMessage.includes('fix conflicts');
3325
+
3326
+ if (isConflict) {
3327
+ const status = await git.status().catch(() => ({ conflicted: [] }));
3328
+ return {
3329
+ success: false,
3330
+ conflict: true,
3331
+ conflictFiles: status.conflicted || []
3332
+ };
3333
+ }
3334
+
3335
+ // "nothing to commit" can happen if all conflicts resolved to one side
3336
+ if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
3337
+ // The merge is effectively complete (all changes already committed or no changes needed)
3338
+ return { success: true, conflict: false };
3339
+ }
3340
+
3341
+ console.error('Failed to continue merge:', error);
3342
+ throw error;
3343
+ }
3344
+ }
3345
+
3346
+ export async function getConflictDetails(directory) {
3347
+ const directoryPath = normalizeDirectoryPath(directory);
3348
+ const git = await createGit(directoryPath);
3349
+
3350
+ try {
3351
+ // Get git status --porcelain
3352
+ const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
3353
+
3354
+ // Get unmerged files
3355
+ const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
3356
+ const unmergedFiles = unmergedFilesRaw
3357
+ .split('\n')
3358
+ .map((line) => line.trim())
3359
+ .filter(Boolean);
3360
+
3361
+ // Get current diff
3362
+ const diff = await git.raw(['diff']).catch(() => '');
3363
+
3364
+ // Detect operation type and get head info
3365
+ let operation = 'merge';
3366
+ let headInfo = '';
3367
+
3368
+ // Check for MERGE_HEAD (merge in progress)
3369
+ const mergeHeadExists = await git
3370
+ .raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
3371
+ .then(() => true)
3372
+ .catch(() => false);
3373
+
3374
+ if (mergeHeadExists) {
3375
+ operation = 'merge';
3376
+ const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
3377
+ const mergeMsg = await fsp
3378
+ .readFile(path.join(directoryPath, '.git', 'MERGE_MSG'), 'utf8')
3379
+ .catch(() => '');
3380
+ headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
3381
+ } else {
3382
+ // Check for REBASE_HEAD (rebase in progress)
3383
+ const rebaseHeadExists = await git
3384
+ .raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
3385
+ .then(() => true)
3386
+ .catch(() => false);
3387
+
3388
+ if (rebaseHeadExists) {
3389
+ operation = 'rebase';
3390
+ const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
3391
+ headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
3392
+ }
3393
+ }
3394
+
3395
+ return {
3396
+ statusPorcelain: statusPorcelain.trim(),
3397
+ unmergedFiles,
3398
+ diff: diff.trim(),
3399
+ headInfo: headInfo.trim(),
3400
+ operation,
3401
+ };
3402
+ } catch (error) {
3403
+ console.error('Failed to get conflict details:', error);
3404
+ throw error;
3405
+ }
3406
+ }
3407
+
3408
+ export async function getCommitFileDiff(directory, hash, filePath, isBinary) {
3409
+ if (!directory || !hash || !filePath) {
3410
+ throw new Error('directory, hash, and path are required for getCommitFileDiff');
3411
+ }
3412
+
3413
+ if (isBinary) {
3414
+ return { original: '', modified: '', isBinary: true };
3415
+ }
3416
+
3417
+ const directoryPath = normalizeDirectoryPath(directory);
3418
+
3419
+ const [originalResult, modifiedResult] = await Promise.all([
3420
+ runGitCommand(directoryPath, ['show', `${hash}^:${filePath}`]),
3421
+ runGitCommand(directoryPath, ['show', `${hash}:${filePath}`]),
3422
+ ]);
3423
+
3424
+ const original = originalResult.success ? originalResult.stdout : '';
3425
+ const modified = modifiedResult.success ? modifiedResult.stdout : '';
3426
+
3427
+ if (!originalResult.success && !modifiedResult.success) {
3428
+ throw new Error(`Failed to read file content at commit ${hash}: ${originalResult.stderr || modifiedResult.stderr}`);
3429
+ }
3430
+
3431
+ return { original, modified, isBinary: false };
3432
+ }