pi-kot 0.1.21 → 0.1.22

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 (348) hide show
  1. package/bin/pi-kot.mjs +148 -0
  2. package/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  3. package/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  4. package/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  5. package/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  6. package/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  7. package/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  8. package/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  9. package/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  10. package/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  11. package/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  12. package/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  13. package/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  14. package/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  15. package/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  16. package/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  17. package/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  18. package/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  19. package/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  20. package/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  21. package/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  22. package/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  23. package/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  24. package/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  25. package/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  26. package/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  27. package/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  28. package/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  29. package/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  30. package/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  31. package/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  32. package/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  33. package/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  34. package/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  35. package/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  36. package/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  37. package/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  38. package/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  39. package/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  40. package/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  41. package/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  42. package/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  43. package/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  44. package/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  45. package/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  46. package/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  47. package/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  48. package/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  49. package/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  50. package/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  51. package/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  52. package/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  53. package/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  54. package/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  55. package/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  56. package/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  57. package/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  58. package/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  59. package/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  60. package/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  61. package/client/assets/index-CE_Na97I.js +75 -0
  62. package/client/assets/index-CE_Na97I.js.map +1 -0
  63. package/client/assets/index-QP95zkpd.css +1 -0
  64. package/client/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  65. package/client/assets/vendor-codemirror-CD3uCqRj.js +48 -0
  66. package/client/assets/vendor-codemirror-CD3uCqRj.js.map +1 -0
  67. package/client/assets/vendor-icons-W8thHNMl.js +2 -0
  68. package/client/assets/vendor-icons-W8thHNMl.js.map +1 -0
  69. package/client/assets/vendor-katex-4z5K3dyr.js +260 -0
  70. package/client/assets/vendor-katex-4z5K3dyr.js.map +1 -0
  71. package/client/assets/vendor-katex-CYlHEDRF.css +1 -0
  72. package/client/assets/vendor-markdown-BSzxNR_7.js +35 -0
  73. package/client/assets/vendor-markdown-BSzxNR_7.js.map +1 -0
  74. package/client/assets/vendor-prism-CQGp13s2.js +4 -0
  75. package/client/assets/vendor-prism-CQGp13s2.js.map +1 -0
  76. package/client/assets/vendor-xterm-BrP-ENHg.css +1 -0
  77. package/client/assets/vendor-xterm-DXwh06H3.js +36 -0
  78. package/client/assets/vendor-xterm-DXwh06H3.js.map +1 -0
  79. package/client/index.html +23 -0
  80. package/package.json +5 -3
  81. package/server/ask-user-question/envelope.d.ts +11 -0
  82. package/server/ask-user-question/envelope.d.ts.map +1 -0
  83. package/server/ask-user-question/envelope.js +41 -0
  84. package/server/ask-user-question/envelope.js.map +1 -0
  85. package/server/ask-user-question/plan-mode-question-tool.d.ts +26 -0
  86. package/server/ask-user-question/plan-mode-question-tool.d.ts.map +1 -0
  87. package/server/ask-user-question/plan-mode-question-tool.js +273 -0
  88. package/server/ask-user-question/plan-mode-question-tool.js.map +1 -0
  89. package/server/ask-user-question/prompt-strings.d.ts +4 -0
  90. package/server/ask-user-question/prompt-strings.d.ts.map +1 -0
  91. package/server/ask-user-question/prompt-strings.js +18 -0
  92. package/server/ask-user-question/prompt-strings.js.map +1 -0
  93. package/server/ask-user-question/registry.d.ts +39 -0
  94. package/server/ask-user-question/registry.d.ts.map +1 -0
  95. package/server/ask-user-question/registry.js +124 -0
  96. package/server/ask-user-question/registry.js.map +1 -0
  97. package/server/ask-user-question/tool.d.ts +4 -0
  98. package/server/ask-user-question/tool.d.ts.map +1 -0
  99. package/server/ask-user-question/tool.js +68 -0
  100. package/server/ask-user-question/tool.js.map +1 -0
  101. package/server/ask-user-question/types.d.ts +50 -0
  102. package/server/ask-user-question/types.d.ts.map +1 -0
  103. package/server/ask-user-question/types.js +7 -0
  104. package/server/ask-user-question/types.js.map +1 -0
  105. package/server/ask-user-question/validate.d.ts +3 -0
  106. package/server/ask-user-question/validate.d.ts.map +1 -0
  107. package/server/ask-user-question/validate.js +67 -0
  108. package/server/ask-user-question/validate.js.map +1 -0
  109. package/server/clone-provider.d.ts +55 -0
  110. package/server/clone-provider.d.ts.map +1 -0
  111. package/server/clone-provider.js +172 -0
  112. package/server/clone-provider.js.map +1 -0
  113. package/server/compaction-cards.d.ts +62 -0
  114. package/server/compaction-cards.d.ts.map +1 -0
  115. package/server/compaction-cards.js +180 -0
  116. package/server/compaction-cards.js.map +1 -0
  117. package/server/compaction-continuation.d.ts +112 -0
  118. package/server/compaction-continuation.d.ts.map +1 -0
  119. package/server/compaction-continuation.js +65 -0
  120. package/server/compaction-continuation.js.map +1 -0
  121. package/server/config-store.d.ts +97 -0
  122. package/server/config-store.d.ts.map +1 -0
  123. package/server/config-store.js +351 -0
  124. package/server/config-store.js.map +1 -0
  125. package/server/config.d.ts +24 -0
  126. package/server/config.d.ts.map +1 -0
  127. package/server/config.js +67 -0
  128. package/server/config.js.map +1 -0
  129. package/server/event-stream.d.ts +28 -0
  130. package/server/event-stream.d.ts.map +1 -0
  131. package/server/event-stream.js +153 -0
  132. package/server/event-stream.js.map +1 -0
  133. package/server/extension-manager.d.ts +116 -0
  134. package/server/extension-manager.d.ts.map +1 -0
  135. package/server/extension-manager.js +586 -0
  136. package/server/extension-manager.js.map +1 -0
  137. package/server/extension-scanner.d.ts +26 -0
  138. package/server/extension-scanner.d.ts.map +1 -0
  139. package/server/extension-scanner.js +107 -0
  140. package/server/extension-scanner.js.map +1 -0
  141. package/server/extension-ui-bridge.d.ts +35 -0
  142. package/server/extension-ui-bridge.d.ts.map +1 -0
  143. package/server/extension-ui-bridge.js +228 -0
  144. package/server/extension-ui-bridge.js.map +1 -0
  145. package/server/file-manager.d.ts +79 -0
  146. package/server/file-manager.d.ts.map +1 -0
  147. package/server/file-manager.js +535 -0
  148. package/server/file-manager.js.map +1 -0
  149. package/server/file-searcher.d.ts +29 -0
  150. package/server/file-searcher.d.ts.map +1 -0
  151. package/server/file-searcher.js +318 -0
  152. package/server/file-searcher.js.map +1 -0
  153. package/server/git-operations.d.ts +346 -0
  154. package/server/git-operations.d.ts.map +1 -0
  155. package/server/git-operations.js +884 -0
  156. package/server/git-operations.js.map +1 -0
  157. package/server/hunk-stager.d.ts +56 -0
  158. package/server/hunk-stager.d.ts.map +1 -0
  159. package/server/hunk-stager.js +137 -0
  160. package/server/hunk-stager.js.map +1 -0
  161. package/server/index.d.ts +21 -0
  162. package/server/index.d.ts.map +1 -0
  163. package/server/index.js +262 -0
  164. package/server/index.js.map +1 -0
  165. package/server/mcp/config.d.ts +23 -0
  166. package/server/mcp/config.d.ts.map +1 -0
  167. package/server/mcp/config.js +139 -0
  168. package/server/mcp/config.js.map +1 -0
  169. package/server/mcp/manager.d.ts +40 -0
  170. package/server/mcp/manager.d.ts.map +1 -0
  171. package/server/mcp/manager.js +435 -0
  172. package/server/mcp/manager.js.map +1 -0
  173. package/server/mcp/stdio-trust.d.ts +5 -0
  174. package/server/mcp/stdio-trust.d.ts.map +1 -0
  175. package/server/mcp/stdio-trust.js +78 -0
  176. package/server/mcp/stdio-trust.js.map +1 -0
  177. package/server/mcp/tool-bridge.d.ts +33 -0
  178. package/server/mcp/tool-bridge.d.ts.map +1 -0
  179. package/server/mcp/tool-bridge.js +138 -0
  180. package/server/mcp/tool-bridge.js.map +1 -0
  181. package/server/orchestration/config.d.ts +5 -0
  182. package/server/orchestration/config.d.ts.map +1 -0
  183. package/server/orchestration/config.js +35 -0
  184. package/server/orchestration/config.js.map +1 -0
  185. package/server/orchestration/event-bridge.d.ts +32 -0
  186. package/server/orchestration/event-bridge.d.ts.map +1 -0
  187. package/server/orchestration/event-bridge.js +110 -0
  188. package/server/orchestration/event-bridge.js.map +1 -0
  189. package/server/orchestration/inbox.d.ts +9 -0
  190. package/server/orchestration/inbox.d.ts.map +1 -0
  191. package/server/orchestration/inbox.js +186 -0
  192. package/server/orchestration/inbox.js.map +1 -0
  193. package/server/orchestration/init.d.ts +2 -0
  194. package/server/orchestration/init.d.ts.map +1 -0
  195. package/server/orchestration/init.js +22 -0
  196. package/server/orchestration/init.js.map +1 -0
  197. package/server/orchestration/store.d.ts +33 -0
  198. package/server/orchestration/store.d.ts.map +1 -0
  199. package/server/orchestration/store.js +331 -0
  200. package/server/orchestration/store.js.map +1 -0
  201. package/server/orchestration/tools.d.ts +15 -0
  202. package/server/orchestration/tools.d.ts.map +1 -0
  203. package/server/orchestration/tools.js +621 -0
  204. package/server/orchestration/tools.js.map +1 -0
  205. package/server/orchestration/types.d.ts +68 -0
  206. package/server/orchestration/types.d.ts.map +1 -0
  207. package/server/orchestration/types.js +43 -0
  208. package/server/orchestration/types.js.map +1 -0
  209. package/server/orchestration/worker-lifecycle.d.ts +15 -0
  210. package/server/orchestration/worker-lifecycle.d.ts.map +1 -0
  211. package/server/orchestration/worker-lifecycle.js +45 -0
  212. package/server/orchestration/worker-lifecycle.js.map +1 -0
  213. package/server/path-refs.d.ts +5 -0
  214. package/server/path-refs.d.ts.map +1 -0
  215. package/server/path-refs.js +249 -0
  216. package/server/path-refs.js.map +1 -0
  217. package/server/routes/_schemas.d.ts +16 -0
  218. package/server/routes/_schemas.d.ts.map +1 -0
  219. package/server/routes/_schemas.js +12 -0
  220. package/server/routes/_schemas.js.map +1 -0
  221. package/server/routes/ask-user-question.d.ts +3 -0
  222. package/server/routes/ask-user-question.d.ts.map +1 -0
  223. package/server/routes/ask-user-question.js +105 -0
  224. package/server/routes/ask-user-question.js.map +1 -0
  225. package/server/routes/auth.d.ts +8 -0
  226. package/server/routes/auth.d.ts.map +1 -0
  227. package/server/routes/auth.js +225 -0
  228. package/server/routes/auth.js.map +1 -0
  229. package/server/routes/config.d.ts +3 -0
  230. package/server/routes/config.d.ts.map +1 -0
  231. package/server/routes/config.js +602 -0
  232. package/server/routes/config.js.map +1 -0
  233. package/server/routes/control.d.ts +3 -0
  234. package/server/routes/control.d.ts.map +1 -0
  235. package/server/routes/control.js +365 -0
  236. package/server/routes/control.js.map +1 -0
  237. package/server/routes/exec.d.ts +3 -0
  238. package/server/routes/exec.d.ts.map +1 -0
  239. package/server/routes/exec.js +426 -0
  240. package/server/routes/exec.js.map +1 -0
  241. package/server/routes/extension-commands.d.ts +22 -0
  242. package/server/routes/extension-commands.d.ts.map +1 -0
  243. package/server/routes/extension-commands.js +178 -0
  244. package/server/routes/extension-commands.js.map +1 -0
  245. package/server/routes/extensions.d.ts +8 -0
  246. package/server/routes/extensions.d.ts.map +1 -0
  247. package/server/routes/extensions.js +414 -0
  248. package/server/routes/extensions.js.map +1 -0
  249. package/server/routes/files.d.ts +3 -0
  250. package/server/routes/files.d.ts.map +1 -0
  251. package/server/routes/files.js +835 -0
  252. package/server/routes/files.js.map +1 -0
  253. package/server/routes/git.d.ts +3 -0
  254. package/server/routes/git.d.ts.map +1 -0
  255. package/server/routes/git.js +1058 -0
  256. package/server/routes/git.js.map +1 -0
  257. package/server/routes/mcp.d.ts +3 -0
  258. package/server/routes/mcp.d.ts.map +1 -0
  259. package/server/routes/mcp.js +403 -0
  260. package/server/routes/mcp.js.map +1 -0
  261. package/server/routes/orchestration.d.ts +3 -0
  262. package/server/routes/orchestration.d.ts.map +1 -0
  263. package/server/routes/orchestration.js +251 -0
  264. package/server/routes/orchestration.js.map +1 -0
  265. package/server/routes/projects.d.ts +3 -0
  266. package/server/routes/projects.d.ts.map +1 -0
  267. package/server/routes/projects.js +421 -0
  268. package/server/routes/projects.js.map +1 -0
  269. package/server/routes/prompt.d.ts +3 -0
  270. package/server/routes/prompt.d.ts.map +1 -0
  271. package/server/routes/prompt.js +311 -0
  272. package/server/routes/prompt.js.map +1 -0
  273. package/server/routes/session-extensions.d.ts +11 -0
  274. package/server/routes/session-extensions.d.ts.map +1 -0
  275. package/server/routes/session-extensions.js +171 -0
  276. package/server/routes/session-extensions.js.map +1 -0
  277. package/server/routes/sessions.d.ts +3 -0
  278. package/server/routes/sessions.d.ts.map +1 -0
  279. package/server/routes/sessions.js +706 -0
  280. package/server/routes/sessions.js.map +1 -0
  281. package/server/routes/skills.d.ts +14 -0
  282. package/server/routes/skills.d.ts.map +1 -0
  283. package/server/routes/skills.js +448 -0
  284. package/server/routes/skills.js.map +1 -0
  285. package/server/routes/stream.d.ts +3 -0
  286. package/server/routes/stream.d.ts.map +1 -0
  287. package/server/routes/stream.js +112 -0
  288. package/server/routes/stream.js.map +1 -0
  289. package/server/routes/terminal.d.ts +28 -0
  290. package/server/routes/terminal.d.ts.map +1 -0
  291. package/server/routes/terminal.js +211 -0
  292. package/server/routes/terminal.js.map +1 -0
  293. package/server/routes/tunnel.d.ts +13 -0
  294. package/server/routes/tunnel.d.ts.map +1 -0
  295. package/server/routes/tunnel.js +231 -0
  296. package/server/routes/tunnel.js.map +1 -0
  297. package/server/session-alerts.d.ts +28 -0
  298. package/server/session-alerts.d.ts.map +1 -0
  299. package/server/session-alerts.js +38 -0
  300. package/server/session-alerts.js.map +1 -0
  301. package/server/session-store.d.ts +164 -0
  302. package/server/session-store.d.ts.map +1 -0
  303. package/server/session-store.js +754 -0
  304. package/server/session-store.js.map +1 -0
  305. package/server/skill-policy.d.ts +47 -0
  306. package/server/skill-policy.d.ts.map +1 -0
  307. package/server/skill-policy.js +178 -0
  308. package/server/skill-policy.js.map +1 -0
  309. package/server/terminal-provider.d.ts +54 -0
  310. package/server/terminal-provider.d.ts.map +1 -0
  311. package/server/terminal-provider.js +220 -0
  312. package/server/terminal-provider.js.map +1 -0
  313. package/server/tool-policy.d.ts +39 -0
  314. package/server/tool-policy.d.ts.map +1 -0
  315. package/server/tool-policy.js +121 -0
  316. package/server/tool-policy.js.map +1 -0
  317. package/server/tunnel/executable-search.d.ts +43 -0
  318. package/server/tunnel/executable-search.d.ts.map +1 -0
  319. package/server/tunnel/executable-search.js +148 -0
  320. package/server/tunnel/executable-search.js.map +1 -0
  321. package/server/tunnel/install-help.d.ts +19 -0
  322. package/server/tunnel/install-help.d.ts.map +1 -0
  323. package/server/tunnel/install-help.js +47 -0
  324. package/server/tunnel/install-help.js.map +1 -0
  325. package/server/tunnel/providers/ngrok.d.ts +31 -0
  326. package/server/tunnel/providers/ngrok.d.ts.map +1 -0
  327. package/server/tunnel/providers/ngrok.js +461 -0
  328. package/server/tunnel/providers/ngrok.js.map +1 -0
  329. package/server/tunnel/registry.d.ts +13 -0
  330. package/server/tunnel/registry.d.ts.map +1 -0
  331. package/server/tunnel/registry.js +56 -0
  332. package/server/tunnel/registry.js.map +1 -0
  333. package/server/tunnel/service.d.ts +13 -0
  334. package/server/tunnel/service.d.ts.map +1 -0
  335. package/server/tunnel/service.js +156 -0
  336. package/server/tunnel/service.js.map +1 -0
  337. package/server/tunnel/types.d.ts +163 -0
  338. package/server/tunnel/types.d.ts.map +1 -0
  339. package/server/tunnel/types.js +214 -0
  340. package/server/tunnel/types.js.map +1 -0
  341. package/server/ui-settings-store.d.ts +25 -0
  342. package/server/ui-settings-store.d.ts.map +1 -0
  343. package/server/ui-settings-store.js +91 -0
  344. package/server/ui-settings-store.js.map +1 -0
  345. package/server/workspace-store.d.ts +25 -0
  346. package/server/workspace-store.d.ts.map +1 -0
  347. package/server/workspace-store.js +157 -0
  348. package/server/workspace-store.js.map +1 -0
@@ -0,0 +1,884 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpath } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { assertInsideRoot } from "./file-manager.js";
7
+ const execFileAsync = promisify(execFile);
8
+ /**
9
+ * Thin wrapper around `git` for the pi-kot's git panel.
10
+ *
11
+ * Rules:
12
+ * - NEVER `exec` with string interpolation. Always `execFile` with
13
+ * an args array. The project path comes from our own
14
+ * `workspace-store` (validated against WORKSPACE_PATH); commit
15
+ * messages and remote/branch names come from user input — args
16
+ * arrays make shell-quoting moot regardless of content.
17
+ * - "Not a git repo" → return empty / sensible default, NEVER 500.
18
+ * Users can have non-git project folders and the panel should
19
+ * just sit quiet, not error.
20
+ * - User-visible errors carry a short message we synthesize from
21
+ * the stderr; we never blast raw stderr at the client (would
22
+ * leak fs paths + git plumbing detail).
23
+ *
24
+ * Output buffer: 16 MB on every call. Plenty for `diff` and `log
25
+ * --oneline -30` even on monorepos; if a future `log` query needs
26
+ * more, we'll cap it explicitly.
27
+ */
28
+ const MAX_BUFFER = 16 * 1024 * 1024;
29
+ /* ----------------------------- errors ----------------------------- */
30
+ export class GitNotInstalledError extends Error {
31
+ constructor() {
32
+ super("git binary not found on PATH");
33
+ this.name = "GitNotInstalledError";
34
+ }
35
+ }
36
+ export class GitCommandError extends Error {
37
+ exitCode;
38
+ /** Stable route error code for user-actionable git failures. */
39
+ errorCode;
40
+ /** Sanitized first line of stderr — safe to surface to the user. */
41
+ userMessage;
42
+ constructor(exitCode, userMessage, fullMessage, errorCode = "git_failed") {
43
+ super(fullMessage);
44
+ this.name = "GitCommandError";
45
+ this.exitCode = exitCode;
46
+ this.errorCode = errorCode;
47
+ this.userMessage = userMessage;
48
+ }
49
+ }
50
+ /**
51
+ * List files changed in a specific commit.
52
+ * Uses `git diff-tree --no-commit-id -r --name-status <hash>`
53
+ * which works for root commits (diffs against empty tree) and
54
+ * normal commits. For merge commits it shows a combined diff
55
+ * (files that differ from both parents).
56
+ */
57
+ export async function getCommitFiles(cwd, hash) {
58
+ const { stdout } = await runGit(cwd, [
59
+ "diff-tree",
60
+ "--no-commit-id",
61
+ "-r",
62
+ "--name-status",
63
+ hash,
64
+ ]);
65
+ if (stdout.length === 0)
66
+ return [];
67
+ return stdout
68
+ .trim()
69
+ .split("\n")
70
+ .filter(Boolean)
71
+ .map((line) => {
72
+ const [status, ...pathParts] = line.split("\t");
73
+ const path = pathParts.join("\t");
74
+ return {
75
+ path,
76
+ changeType: (status?.charAt(0) ?? "M"),
77
+ };
78
+ });
79
+ }
80
+ /**
81
+ * Get the unified diff for a single file as it was changed in a specific commit.
82
+ * Uses `git diff-tree --no-commit-id -p -r <hash> -- <path>`.
83
+ * Works for root commits and merge commits (combined diff).
84
+ */
85
+ export async function getCommitFileDiff(cwd, hash, path) {
86
+ const { stdout } = await runGit(cwd, [
87
+ "diff-tree",
88
+ "--no-commit-id",
89
+ "-p",
90
+ "-r",
91
+ hash,
92
+ "--",
93
+ path,
94
+ ]);
95
+ return stdout;
96
+ }
97
+ /**
98
+ * Run `git <args>` in `cwd`. Resolves on exit code 0; rejects with
99
+ * `GitCommandError` (with sanitized userMessage) otherwise.
100
+ *
101
+ * `GIT_TERMINAL_PROMPT=0` keeps git from blocking on interactive
102
+ * credential prompts when the user pushes without configured creds —
103
+ * we want a fast 4xx instead of a hung process. Same for
104
+ * `GIT_ASKPASS` set to `true` (the no-op binary).
105
+ */
106
+ /**
107
+ * `-c` flags prepended to every git invocation so a hostile per-repo
108
+ * `.git/config` can't get the pi-kot to execute arbitrary commands
109
+ * via `core.fsmonitor` / `core.editor` / `core.pager` /
110
+ * `core.sshCommand` / `core.askPass`. Cloning a third-party repo is a
111
+ * normal flow; the cloned repo's local config CAN ship hostile values
112
+ * for these keys (the initial clone is from upstream and doesn't apply
113
+ * the local config, but every subsequent `git status` etc. does).
114
+ *
115
+ * Setting these to safe defaults at invocation time overrides any
116
+ * value the repo's `.git/config` set. Reference:
117
+ * https://github.blog/2022-04-12-git-security-vulnerability-announced/
118
+ */
119
+ const HARDENING_ARGS = [
120
+ "-c",
121
+ "core.fsmonitor=",
122
+ "-c",
123
+ "core.askPass=",
124
+ "-c",
125
+ "core.sshCommand=ssh",
126
+ "-c",
127
+ "core.editor=true",
128
+ "-c",
129
+ "core.pager=cat",
130
+ ];
131
+ async function runGit(cwd, args) {
132
+ try {
133
+ const { stdout, stderr } = await execFileAsync("git", [...HARDENING_ARGS, ...args], {
134
+ cwd,
135
+ maxBuffer: MAX_BUFFER,
136
+ env: gitEnv(),
137
+ });
138
+ return { stdout, stderr, exitCode: 0 };
139
+ }
140
+ catch (err) {
141
+ const e = err;
142
+ if (e.code === "ENOENT")
143
+ throw new GitNotInstalledError();
144
+ const stderr = (e.stderr ?? "").toString();
145
+ const authRequired = isGitAuthRequired(stderr);
146
+ const userMessage = authRequired ? gitAuthRequiredMessage() : sanitizeStderr(stderr, cwd);
147
+ const exitCode = typeof e.code === "number" ? e.code : null;
148
+ throw new GitCommandError(exitCode, userMessage, stderr || (e.message ?? "git failed"), authRequired ? "git_auth_required" : "git_failed");
149
+ }
150
+ }
151
+ /**
152
+ * Build the env object every git invocation should use. Centralising it
153
+ * here lets `runGit` (the typed-error wrapper) and `runGitRaw` (the
154
+ * permissive escape hatch used by `turn-diff-builder`) share the same
155
+ * GIT_TERMINAL_PROMPT / GIT_ASKPASS / LC_ALL / HOME scrubbing.
156
+ *
157
+ * `git config` consults $HOME for the user's global config. If the
158
+ * parent process has no HOME (some container init flows, certain
159
+ * systemd/launchd configurations), git falls back to /etc/passwd
160
+ * lookup which can fail opaquely. Force a sensible default from
161
+ * `os.homedir()` (which itself checks USERPROFILE on Windows + falls
162
+ * back to the passwd entry).
163
+ */
164
+ function gitEnv() {
165
+ return {
166
+ ...process.env,
167
+ HOME: process.env.HOME ?? homedir(),
168
+ GIT_TERMINAL_PROMPT: "0",
169
+ GIT_ASKPASS: "true",
170
+ LC_ALL: "C",
171
+ };
172
+ }
173
+ /**
174
+ * Permissive runner for callers that need a custom maxBuffer or
175
+ * non-typed-error semantics. Returns the raw stdout/stderr/exitCode
176
+ * tuple WITHOUT mapping non-zero exit codes to GitCommandError —
177
+ * callers handle exit codes themselves. Inherits the same env
178
+ * scrubbing as `runGit`.
179
+ *
180
+ * Currently used by `turn-diff-builder.ts` which wants `git diff` to
181
+ * succeed even when the path is untracked (exit 0 with empty output)
182
+ * and accepts a 16 MB diff buffer.
183
+ */
184
+ export async function runGitRaw(cwd, args, opts = {}) {
185
+ try {
186
+ const { stdout, stderr } = await execFileAsync("git", [...HARDENING_ARGS, ...args], {
187
+ cwd,
188
+ maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
189
+ env: gitEnv(),
190
+ });
191
+ return { stdout, stderr };
192
+ }
193
+ catch (err) {
194
+ if (err.code === "ENOENT")
195
+ throw new GitNotInstalledError();
196
+ throw err;
197
+ }
198
+ }
199
+ /**
200
+ * Trim git's stderr to a one-line user-visible message. Strips the
201
+ * project root path (would otherwise leak filesystem layout — `git
202
+ * push` failing with "fatal: unable to access '/Users/.../foo/.git/'"
203
+ * would echo the host path back to the browser). Also drops the
204
+ * common "fatal: "/"error: "/"warning: " prefix and clamps to 200
205
+ * chars. Single-tenant doesn't make this a security issue per se,
206
+ * but the previous comment claimed scrubbing happened when it didn't
207
+ * — bringing the implementation in line with the documented behavior.
208
+ */
209
+ function sanitizeStderr(stderr, cwd) {
210
+ let firstLine = stderr.split("\n").find((l) => l.trim().length > 0) ?? "git error";
211
+ if (cwd !== undefined && cwd.length > 0) {
212
+ firstLine = firstLine.split(cwd).join("<project>");
213
+ }
214
+ firstLine = redactCredentialUrls(firstLine);
215
+ // Drop common "fatal: " / "error: " prefixes that confuse users.
216
+ const stripped = firstLine.replace(/^(fatal|error|warning):\s*/i, "");
217
+ return stripped.length > 200 ? stripped.slice(0, 197) + "…" : stripped;
218
+ }
219
+ function redactCredentialUrls(message) {
220
+ return message.replace(/(https?:\/\/)([^\s/@:]+)(?::[^\s/@]*)?@/gi, "$1<credentials>@");
221
+ }
222
+ function isGitAuthRequired(stderr) {
223
+ return /authentication failed|could not read (username|password).*terminal prompts disabled|terminal prompts disabled|permission denied \(publickey\)|permission denied .*publickey|authentication required|authorization failed|repository not found/i.test(stderr);
224
+ }
225
+ function gitAuthRequiredMessage() {
226
+ return ("Git authentication required. Configure this remote's credentials in the integrated " +
227
+ "terminal or system Git credential helper, then retry. pi-kot does not collect or store " +
228
+ "Git passwords or tokens.");
229
+ }
230
+ /**
231
+ * Initialize a fresh git repo at `cwd` with `main` as the initial
232
+ * branch. `git init -b main` requires git ≥ 2.28; below that the
233
+ * `--initial-branch` flag is unrecognized and we fall back to a
234
+ * plain `git init` — caller can still rename to main on the first
235
+ * commit if desired. Idempotent: if `cwd` is already a repo, this
236
+ * resolves without changing anything (git's own `init` is a no-op
237
+ * on an existing repo).
238
+ */
239
+ export async function initRepo(cwd) {
240
+ try {
241
+ await runGit(cwd, ["init", "-b", "main"]);
242
+ }
243
+ catch (err) {
244
+ // Older git versions (< 2.28) don't recognise `-b`. Detect via
245
+ // the stderr message and retry without it. Other errors propagate.
246
+ const msg = err instanceof Error ? err.message : "";
247
+ if (/unknown (option|switch).*-b|invalid option.*initial-branch/i.test(msg)) {
248
+ await runGit(cwd, ["init"]);
249
+ return;
250
+ }
251
+ throw err;
252
+ }
253
+ }
254
+ /**
255
+ * True iff `cwd` is inside a git working tree. Cheap probe used by
256
+ * every public function so "not a repo" can return the empty default
257
+ * rather than throw. Exported so route helpers (e.g. for the diff
258
+ * endpoints' `isGitRepo` flag) don't have to re-implement it.
259
+ */
260
+ export async function isGitRepo(cwd) {
261
+ try {
262
+ await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
263
+ return true;
264
+ }
265
+ catch {
266
+ // GitCommandError (non-zero exit, common: "not a git repository"),
267
+ // GitNotInstalledError, or fs error. All collapse to "not a repo"
268
+ // — the panel renders an empty state without distinguishing.
269
+ return false;
270
+ }
271
+ }
272
+ /* ----------------------------- status ----------------------------- */
273
+ /**
274
+ * `git status --porcelain=v1 -uall -z` output. Records are NUL-
275
+ * terminated (no quoting / escaping), so paths containing literal
276
+ * newlines, quotes, or other special chars round-trip cleanly.
277
+ *
278
+ * Each record is `XY <path>` (length ≥ 4 with the leading XY + space).
279
+ * Renames and copies are special: `XY <newpath>` is followed by a
280
+ * SECOND NUL-terminated record containing only the original path. We
281
+ * peek at the next token in that case rather than splitting on " -> ".
282
+ */
283
+ function parseStatus(stdout) {
284
+ const out = [];
285
+ // Trailing NUL produces an empty final element — drop it.
286
+ const records = stdout.split("\0").filter((r) => r.length > 0);
287
+ for (let i = 0; i < records.length; i++) {
288
+ const rec = records[i] ?? "";
289
+ if (rec.length < 4)
290
+ continue;
291
+ const code = rec.slice(0, 2);
292
+ const path = rec.slice(3);
293
+ const x = code[0] ?? " ";
294
+ const y = code[1] ?? " ";
295
+ let originalPath;
296
+ // For renames/copies, the next record is the ORIGINAL path. Peek
297
+ // and consume.
298
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
299
+ const next = records[i + 1];
300
+ if (next !== undefined) {
301
+ originalPath = next;
302
+ i++;
303
+ }
304
+ }
305
+ if (path.length === 0)
306
+ continue;
307
+ const staged = x !== " " && x !== "?";
308
+ const unstaged = y !== " " && y !== "?";
309
+ const entry = {
310
+ path,
311
+ staged,
312
+ unstaged,
313
+ kind: classifyStatus(x, y),
314
+ code,
315
+ };
316
+ if (originalPath !== undefined)
317
+ entry.originalPath = originalPath;
318
+ out.push(entry);
319
+ }
320
+ return out;
321
+ }
322
+ function classifyStatus(x, y) {
323
+ if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
324
+ return "conflicted";
325
+ }
326
+ if (x === "?" && y === "?")
327
+ return "untracked";
328
+ if (x === "!" || y === "!")
329
+ return "ignored";
330
+ // Prefer the staged side's classification — it's "what will be
331
+ // committed". Fall back to the unstaged side.
332
+ const c = x !== " " && x !== "?" ? x : y;
333
+ switch (c) {
334
+ case "M":
335
+ return "modified";
336
+ case "A":
337
+ return "added";
338
+ case "D":
339
+ return "deleted";
340
+ case "R":
341
+ return "renamed";
342
+ case "C":
343
+ return "copied";
344
+ default:
345
+ return "unknown";
346
+ }
347
+ }
348
+ export async function getStatus(cwd) {
349
+ if (!(await isGitRepo(cwd))) {
350
+ return { isGitRepo: false, branch: undefined, files: [] };
351
+ }
352
+ const [branchRes, statusRes] = await Promise.all([
353
+ runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => undefined),
354
+ runGit(cwd, ["status", "--porcelain=v1", "-uall", "-z"]),
355
+ ]);
356
+ // `--abbrev-ref HEAD` returns "HEAD" on a detached checkout; surface
357
+ // that verbatim so the UI can render it.
358
+ const branch = branchRes?.stdout.trim();
359
+ return {
360
+ isGitRepo: true,
361
+ branch: branch !== undefined && branch.length > 0 ? branch : undefined,
362
+ files: parseStatus(statusRes.stdout),
363
+ };
364
+ }
365
+ async function diffArgs(cwd, args) {
366
+ if (!(await isGitRepo(cwd)))
367
+ return { isGitRepo: false, diff: "" };
368
+ const baseArgs = ["diff", "--no-color", "--no-ext-diff", ...args];
369
+ const { stdout } = await runGit(cwd, baseArgs);
370
+ return { isGitRepo: true, diff: stdout };
371
+ }
372
+ export function getDiff(cwd) {
373
+ return diffArgs(cwd, []);
374
+ }
375
+ export function getStagedDiff(cwd) {
376
+ return diffArgs(cwd, ["--cached"]);
377
+ }
378
+ export async function getFileDiff(cwd, path, staged) {
379
+ if (!(await isGitRepo(cwd)))
380
+ return { isGitRepo: false, diff: "" };
381
+ // Belt-and-suspenders lexical guard. git itself rejects paths outside
382
+ // the working tree, but routing every path through the same check
383
+ // file-manager uses keeps the boundary obvious in one place. `path`
384
+ // arrives relative-to-project from the route, so resolve against cwd
385
+ // before checking.
386
+ assertInsideRoot(resolve(cwd, path), cwd);
387
+ const args = ["diff", "--no-color", "--no-ext-diff"];
388
+ if (staged)
389
+ args.push("--cached");
390
+ args.push("--", path);
391
+ const { stdout } = await runGit(cwd, args);
392
+ return { isGitRepo: true, diff: stdout };
393
+ }
394
+ export async function getLog(cwd, limit = 30) {
395
+ if (!(await isGitRepo(cwd)))
396
+ return { isGitRepo: false, commits: [] };
397
+ // Custom format with NUL field separators and RS record separator.
398
+ // Avoids ambiguity if a commit message has any character we'd
399
+ // otherwise pick as a delimiter. New fields:
400
+ // %P → space-separated parent hashes (empty for root, 2+ for merges)
401
+ // %D → ref decorations like "HEAD -> main, origin/main, tag: v1"
402
+ // We pass `HEAD --branches --tags --remotes` so branches that
403
+ // aren't ancestors of HEAD still surface — the graph renderer wants
404
+ // the full topology, not just first-parent ancestry from the
405
+ // current branch. Explicit `HEAD` keeps the checked-out branch in
406
+ // the result even if many older branch tips would otherwise crowd
407
+ // it out at the `--max-count` boundary. `--topo-order` keeps
408
+ // commits from disjoint branches grouped instead of date-interleaved
409
+ // so the graph reads cleanly.
410
+ const FS = "\x1F";
411
+ const RS = "\x1E";
412
+ const fmt = `%H${FS}%s${FS}%an${FS}%aI${FS}%P${FS}%D${RS}`;
413
+ const { stdout } = await runGit(cwd, [
414
+ "log",
415
+ "--topo-order",
416
+ "HEAD",
417
+ "--branches",
418
+ "--tags",
419
+ "--remotes",
420
+ `--max-count=${Math.max(1, Math.min(limit, 1000))}`,
421
+ `--pretty=format:${fmt}`,
422
+ ]);
423
+ if (stdout.length === 0)
424
+ return { isGitRepo: true, commits: [] };
425
+ const commits = stdout
426
+ .split(RS)
427
+ .map((rec) => rec.replace(/^\n/, ""))
428
+ .filter((rec) => rec.length > 0)
429
+ .map((rec) => {
430
+ const [hash = "", message = "", author = "", date = "", parentsRaw = "", refsRaw = ""] = rec.split(FS);
431
+ const parents = parentsRaw.length > 0 ? parentsRaw.split(" ").filter((p) => p.length > 0) : [];
432
+ const refs = refsRaw.length > 0
433
+ ? refsRaw
434
+ .split(",")
435
+ .map((r) => r.trim())
436
+ .filter((r) => r.length > 0)
437
+ : [];
438
+ return { hash, message, author, date, parents, refs };
439
+ });
440
+ return { isGitRepo: true, commits };
441
+ }
442
+ export async function getRemotes(cwd) {
443
+ if (!(await isGitRepo(cwd)))
444
+ return { isGitRepo: false, remotes: [] };
445
+ const { stdout } = await runGit(cwd, ["remote", "-v"]);
446
+ const map = new Map();
447
+ for (const line of stdout.split("\n")) {
448
+ if (line.length === 0)
449
+ continue;
450
+ // `name<TAB>url (fetch|push)`
451
+ const m = /^(\S+)\s+(.+?)\s+\((fetch|push)\)$/.exec(line);
452
+ if (m === null)
453
+ continue;
454
+ const name = m[1] ?? "";
455
+ const url = m[2] ?? "";
456
+ const dir = m[3] === "push" ? "push" : "fetch";
457
+ if (name.length === 0)
458
+ continue;
459
+ const existing = map.get(name);
460
+ if (existing === undefined) {
461
+ // Pre-fill the OTHER URL with the same value; if the second
462
+ // line for this remote contradicts, we overwrite below.
463
+ map.set(name, { name, fetchUrl: url, pushUrl: url, insecureTls: false });
464
+ }
465
+ else if (dir === "push") {
466
+ existing.pushUrl = url;
467
+ }
468
+ else {
469
+ existing.fetchUrl = url;
470
+ }
471
+ }
472
+ const remotes = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
473
+ await Promise.all(remotes.map(async (remote) => {
474
+ remote.insecureTls = await remoteHasInsecureTls(cwd, remote);
475
+ }));
476
+ return { isGitRepo: true, remotes };
477
+ }
478
+ /**
479
+ * Add a remote. Same name validator as branch creation reused via
480
+ * `assertRemoteName`. The URL is passed verbatim to git as a
481
+ * positional arg; `execFile` (no shell) means there's no command-
482
+ * injection surface even if the URL contains spaces or shell
483
+ * metachars. Common shapes: `https://github.com/foo/bar.git`,
484
+ * `git@github.com:foo/bar.git`, `file:///abs/path`.
485
+ */
486
+ export async function addRemote(cwd, name, url, opts = {}) {
487
+ assertRemoteName(name);
488
+ assertRemoteUrl(url);
489
+ await runGit(cwd, ["remote", "add", name, url]);
490
+ if (opts.insecureTls === true) {
491
+ await setUrlInsecureTls(cwd, url, true);
492
+ }
493
+ }
494
+ function assertRemoteUrl(url) {
495
+ if (url.length === 0 || url.length > 1024) {
496
+ throw new InvalidBranchNameError(`invalid remote URL`);
497
+ }
498
+ // Reject leading dash so the URL can't be parsed as a flag if a
499
+ // future code path drops the `--` separator. `git remote add`
500
+ // ignores `--` in current versions, but defensive.
501
+ if (url.startsWith("-")) {
502
+ throw new InvalidBranchNameError(`invalid remote URL`);
503
+ }
504
+ }
505
+ async function getRemoteUrls(cwd, name) {
506
+ assertRemoteName(name);
507
+ const urls = new Set();
508
+ const fetchUrl = await runGit(cwd, ["remote", "get-url", name]);
509
+ const pushUrl = await runGit(cwd, ["remote", "get-url", "--push", name]).catch(() => undefined);
510
+ const fetch = fetchUrl.stdout.trim();
511
+ const push = pushUrl?.stdout.trim();
512
+ if (fetch.length > 0)
513
+ urls.add(fetch);
514
+ if (push !== undefined && push.length > 0)
515
+ urls.add(push);
516
+ return [...urls];
517
+ }
518
+ async function remoteHasInsecureTls(cwd, remote) {
519
+ const urls = new Set([remote.fetchUrl, remote.pushUrl].filter((url) => url.length > 0));
520
+ for (const url of urls) {
521
+ try {
522
+ const { stdout } = await runGit(cwd, [
523
+ "config",
524
+ "--local",
525
+ "--get-urlmatch",
526
+ "http.sslVerify",
527
+ url,
528
+ ]);
529
+ if (stdout.trim().toLowerCase() === "false")
530
+ return true;
531
+ }
532
+ catch {
533
+ // No local URL-specific match for this URL.
534
+ }
535
+ }
536
+ return false;
537
+ }
538
+ async function setUrlInsecureTls(cwd, url, enabled) {
539
+ assertRemoteUrl(url);
540
+ const key = `http.${url}.sslVerify`;
541
+ if (enabled) {
542
+ await runGit(cwd, ["config", "--local", key, "false"]);
543
+ return;
544
+ }
545
+ await runGit(cwd, ["config", "--local", "--unset-all", key]).catch(() => undefined);
546
+ }
547
+ export async function setRemoteInsecureTls(cwd, name, enabled) {
548
+ const urls = await getRemoteUrls(cwd, name);
549
+ await Promise.all(urls.map((url) => setUrlInsecureTls(cwd, url, enabled)));
550
+ }
551
+ /**
552
+ * Remove a remote. Idempotent at the route layer — git emits
553
+ * "fatal: No such remote" if the name is unknown, which surfaces as
554
+ * the existing 400 git_failed; the route layer can choose to map
555
+ * that to 404 if needed.
556
+ */
557
+ export async function removeRemote(cwd, name) {
558
+ assertRemoteName(name);
559
+ await runGit(cwd, ["remote", "remove", name]);
560
+ }
561
+ /* ----------------------------- branches ----------------------------- */
562
+ export async function getWorktrees(cwd) {
563
+ if (!(await isGitRepo(cwd)))
564
+ return { isGitRepo: false, worktrees: [] };
565
+ const currentPath = await canonicalPath(cwd);
566
+ const { stdout } = await runGit(cwd, ["worktree", "list", "--porcelain", "-z"]);
567
+ const worktrees = [];
568
+ let entry;
569
+ const finish = async () => {
570
+ if (entry !== undefined) {
571
+ const path = await canonicalPath(entry.path);
572
+ worktrees.push({ ...entry, path, current: path === currentPath });
573
+ entry = undefined;
574
+ }
575
+ };
576
+ for (const token of stdout.split("\0")) {
577
+ if (token.length === 0) {
578
+ await finish();
579
+ continue;
580
+ }
581
+ if (token.startsWith("worktree ")) {
582
+ await finish();
583
+ const path = token.slice("worktree ".length);
584
+ entry = {
585
+ path,
586
+ head: undefined,
587
+ branch: undefined,
588
+ bare: false,
589
+ detached: false,
590
+ current: false,
591
+ };
592
+ }
593
+ else if (entry !== undefined && token.startsWith("HEAD ")) {
594
+ entry.head = token.slice("HEAD ".length);
595
+ }
596
+ else if (entry !== undefined && token.startsWith("branch ")) {
597
+ const branch = token.slice("branch ".length);
598
+ entry.branch = branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
599
+ }
600
+ else if (entry !== undefined && token === "bare") {
601
+ entry.bare = true;
602
+ }
603
+ else if (entry !== undefined && token === "detached") {
604
+ entry.detached = true;
605
+ }
606
+ }
607
+ await finish();
608
+ return { isGitRepo: true, worktrees };
609
+ }
610
+ async function canonicalPath(path) {
611
+ try {
612
+ return await realpath(path);
613
+ }
614
+ catch {
615
+ return resolve(path);
616
+ }
617
+ }
618
+ export async function getBranches(cwd) {
619
+ if (!(await isGitRepo(cwd)))
620
+ return { isGitRepo: false, current: undefined, branches: [] };
621
+ const { stdout } = await runGit(cwd, ["branch", "-a", "--format=%(HEAD)\x1F%(refname:short)"]);
622
+ const branches = [];
623
+ let current;
624
+ for (const line of stdout.split("\n")) {
625
+ if (line.length === 0)
626
+ continue;
627
+ const [headFlag = "", name = ""] = line.split("\x1F");
628
+ if (name.length === 0)
629
+ continue;
630
+ // git emits "(HEAD detached at ...)" as a pseudo-ref; skip.
631
+ if (name.startsWith("("))
632
+ continue;
633
+ const isCurrent = headFlag === "*";
634
+ // git's --format always prefixes remote-tracking branches with
635
+ // `remotes/`. The earlier `origin/` heuristic mis-classified a
636
+ // local branch literally named `origin/feature` as remote.
637
+ const remote = name.startsWith("remotes/");
638
+ const cleanName = remote ? name.slice("remotes/".length) : name;
639
+ branches.push({ name: cleanName, current: isCurrent, remote });
640
+ if (isCurrent)
641
+ current = cleanName;
642
+ }
643
+ return { isGitRepo: true, current, branches };
644
+ }
645
+ /* ----------------------------- mutations ----------------------------- */
646
+ export async function stagePaths(cwd, paths) {
647
+ if (paths.length === 0)
648
+ return;
649
+ await runGit(cwd, ["add", "--", ...paths]);
650
+ }
651
+ export async function unstagePaths(cwd, paths) {
652
+ if (paths.length === 0)
653
+ return;
654
+ await runGit(cwd, ["restore", "--staged", "--", ...paths]);
655
+ }
656
+ /**
657
+ * Discard local changes for the given files: restores both the
658
+ * index AND the working tree to HEAD via `git restore --staged
659
+ * --worktree --source=HEAD -- <paths>`. The user-visible "Revert"
660
+ * action.
661
+ *
662
+ * For untracked files, `git restore` errors with "pathspec did
663
+ * not match any file(s) known to git". The route surfaces this
664
+ * via `GitCommandError` so the UI can display "untracked files
665
+ * can't be reverted; delete them via the file browser instead."
666
+ *
667
+ * Destructive — the caller is expected to gate this behind a
668
+ * confirmation in the UI (the click-twice-to-confirm pattern in
669
+ * GitPanel).
670
+ */
671
+ export async function revertPaths(cwd, paths) {
672
+ if (paths.length === 0)
673
+ return;
674
+ await runGit(cwd, ["restore", "--staged", "--worktree", "--source=HEAD", "--", ...paths]);
675
+ }
676
+ /**
677
+ * Commit the currently-staged changes. Empty / whitespace-only
678
+ * messages are rejected at the route layer; this just runs the
679
+ * command. `--no-verify` is NOT used — we want pre-commit hooks to
680
+ * fire so the user's lint/test/format checks gate browser commits
681
+ * the same way they gate terminal commits.
682
+ */
683
+ export async function commit(cwd, message) {
684
+ await runGit(cwd, ["commit", "-m", message]);
685
+ // Capture the new HEAD's hash so the route can echo it back —
686
+ // useful for the UI to highlight "your commit" in the log section.
687
+ const { stdout } = await runGit(cwd, ["rev-parse", "HEAD"]);
688
+ return { hash: stdout.trim() };
689
+ }
690
+ /* ----------------------------- branch ops ----------------------------- */
691
+ /**
692
+ * Restrict branch names to the same character set git itself accepts in
693
+ * common usage — letters, digits, dot, dash, underscore, slash. Reject
694
+ * anything else (spaces, control chars, leading dash that could be
695
+ * mistaken for a flag, dot-only segments, double slashes, etc.) with a
696
+ * single error code so the route can return a stable 400.
697
+ */
698
+ export class InvalidBranchNameError extends Error {
699
+ constructor(name) {
700
+ super(`invalid branch name: ${JSON.stringify(name)}`);
701
+ this.name = "InvalidBranchNameError";
702
+ }
703
+ }
704
+ function assertBranchName(name) {
705
+ if (name.length === 0 || name.length > 200)
706
+ throw new InvalidBranchNameError(name);
707
+ if (!/^[A-Za-z0-9._/-]+$/.test(name))
708
+ throw new InvalidBranchNameError(name);
709
+ if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) {
710
+ throw new InvalidBranchNameError(name);
711
+ }
712
+ if (name.includes("//") || name.includes("..") || name.includes("@{")) {
713
+ throw new InvalidBranchNameError(name);
714
+ }
715
+ // git reserves `HEAD` and a few similar single-token refs.
716
+ if (name === "HEAD" || name === "FETCH_HEAD" || name === "ORIG_HEAD" || name === "MERGE_HEAD") {
717
+ throw new InvalidBranchNameError(name);
718
+ }
719
+ // git's check-ref-format rules we replicate explicitly so the user
720
+ // gets the cleaner `invalid_branch_name` 400 instead of `git_failed`:
721
+ // - no segment may begin with `.` (so `.foo` and `bar/.baz` reject)
722
+ // - no segment may end with `.lock` (git uses .lock files for ref locks)
723
+ // - the whole name may not end with `.`
724
+ if (name.endsWith("."))
725
+ throw new InvalidBranchNameError(name);
726
+ for (const segment of name.split("/")) {
727
+ if (segment.startsWith("."))
728
+ throw new InvalidBranchNameError(name);
729
+ if (segment.endsWith(".lock"))
730
+ throw new InvalidBranchNameError(name);
731
+ }
732
+ }
733
+ /**
734
+ * Switch the working tree to `branch`. Refuses on a dirty tree (git's
735
+ * default) — the caller is expected to surface the resulting
736
+ * `GitCommandError` to the user, who can stash or revert first.
737
+ *
738
+ * No `--` separator: `git checkout -- <name>` interprets <name> as a
739
+ * pathspec and ALWAYS fails with "did not match any file(s) known to
740
+ * git". The branch-name validator (assertBranchName) already rejects
741
+ * leading dashes, so flag injection isn't a concern here.
742
+ */
743
+ export async function checkoutBranch(cwd, branch) {
744
+ assertBranchName(branch);
745
+ await runGit(cwd, ["checkout", branch]);
746
+ }
747
+ /**
748
+ * Create a new local branch. `startPoint` defaults to HEAD; pass
749
+ * `origin/main` (etc.) to branch off a tracking ref. When `checkout`
750
+ * is true, uses `git checkout -b` to create + switch in one step.
751
+ */
752
+ export async function createBranch(cwd, name, opts = {}) {
753
+ assertBranchName(name);
754
+ if (opts.startPoint !== undefined)
755
+ assertBranchName(opts.startPoint);
756
+ if (opts.checkout === true) {
757
+ const args = ["checkout", "-b", name];
758
+ if (opts.startPoint !== undefined)
759
+ args.push(opts.startPoint);
760
+ await runGit(cwd, args);
761
+ }
762
+ else {
763
+ const args = ["branch", name];
764
+ if (opts.startPoint !== undefined)
765
+ args.push(opts.startPoint);
766
+ await runGit(cwd, args);
767
+ }
768
+ }
769
+ /**
770
+ * Delete a local branch. Default uses `-d` (refuses to delete an
771
+ * unmerged branch); `force: true` switches to `-D`. Refuses to delete
772
+ * the currently-checked-out branch (git's default behavior surfaces a
773
+ * `GitCommandError`).
774
+ */
775
+ export async function deleteBranch(cwd, name, opts = {}) {
776
+ assertBranchName(name);
777
+ await runGit(cwd, ["branch", opts.force === true ? "-D" : "-d", name]);
778
+ }
779
+ /**
780
+ * `git fetch [<remote>]` — never touches the working tree, so safe to
781
+ * call regardless of dirty state. Returns the captured output (mostly
782
+ * stderr — git's "Fetching origin\nFrom github.com:foo/bar..." text).
783
+ */
784
+ export async function fetch(cwd, opts = {}) {
785
+ const args = ["fetch"];
786
+ if (opts.prune === true)
787
+ args.push("--prune");
788
+ if (opts.remote !== undefined) {
789
+ assertRemoteName(opts.remote);
790
+ args.push(opts.remote);
791
+ }
792
+ const { stdout, stderr } = await runGit(cwd, args);
793
+ return { stdout: stdout.length > 0 ? stdout : stderr };
794
+ }
795
+ /**
796
+ * `git pull [<remote> [<branch>]]` — fetches AND merges (or rebases).
797
+ * Conflicts are NOT resolved by us — the underlying GitCommandError's
798
+ * stderr is surfaced verbatim (e.g. "CONFLICT (content): Merge
799
+ * conflict in foo.ts") so the user can drop to the integrated
800
+ * terminal to fix.
801
+ *
802
+ * Argument grammar: the second positional is a branch ONLY when the
803
+ * first positional is also given. `git pull <name>` is interpreted
804
+ * as a remote, not a branch — so when the caller passes only `branch`
805
+ * we default `remote` to `"origin"` rather than producing a
806
+ * misleading "remote not found" error.
807
+ */
808
+ export async function pull(cwd, opts = {}) {
809
+ const args = ["pull"];
810
+ if (opts.rebase === true)
811
+ args.push("--rebase");
812
+ const remote = opts.remote ?? (opts.branch !== undefined ? "origin" : undefined);
813
+ if (remote !== undefined) {
814
+ assertRemoteName(remote);
815
+ args.push(remote);
816
+ }
817
+ if (opts.branch !== undefined) {
818
+ assertBranchName(opts.branch);
819
+ args.push(opts.branch);
820
+ }
821
+ const { stdout, stderr } = await runGit(cwd, args);
822
+ return { stdout: stdout.length > 0 ? stdout : stderr };
823
+ }
824
+ export async function push(cwd, opts = {}) {
825
+ const args = ["push"];
826
+ if (opts.setUpstream === true)
827
+ args.push("--set-upstream");
828
+ if (opts.remote !== undefined) {
829
+ assertRemoteName(opts.remote);
830
+ args.push(opts.remote);
831
+ }
832
+ if (opts.branch !== undefined) {
833
+ assertBranchName(opts.branch);
834
+ args.push(opts.branch);
835
+ }
836
+ // Push status info goes to stderr by default; we capture both.
837
+ const { stdout, stderr } = await runGit(cwd, args);
838
+ return { stdout: stdout.length > 0 ? stdout : stderr };
839
+ }
840
+ /* ----------------------------- worktrees ----------------------------- */
841
+ /**
842
+ * Create a linked working tree at `worktreePath` pinned to `commitHash`.
843
+ * `git worktree add` does NOT require a clean working tree in the primary
844
+ * checkout — you can be mid-edit and still create a worktree to try an
845
+ * old commit. Returns the resolved absolute path so the UI can display
846
+ * it.
847
+ *
848
+ * `worktreePath` is resolved against `cwd` — the caller's route should
849
+ * use something like `.git-worktrees/<short-hash>/` relative to the
850
+ * project root.
851
+ */
852
+ export async function addWorktree(cwd, worktreePath, commitHash) {
853
+ await runGit(cwd, ["worktree", "add", worktreePath, commitHash]);
854
+ }
855
+ /**
856
+ * Remove a linked working tree. Git refuses if the worktree has
857
+ * uncommitted changes (non-zero exit) — the route layer surfaces the
858
+ * `GitCommandError` message so the user knows to commit or stash
859
+ * before removing.
860
+ */
861
+ export async function removeWorktree(cwd, worktreePath) {
862
+ await runGit(cwd, ["worktree", "remove", worktreePath]);
863
+ }
864
+ /* ----------------------------- remote validation ----------------------------- */
865
+ /**
866
+ * Validate a git remote name. Rules are looser than branch names:
867
+ * remotes don't reserve `HEAD`/`FETCH_HEAD`/etc., and the `.lock`
868
+ * suffix only matters for ref files. We keep the same character
869
+ * set + leading-dash + traversal guards (the security-relevant
870
+ * ones), but skip the ref-reserved-word and `.lock`/dot-segment
871
+ * checks. A user with a remote literally named `HEAD` (unusual but
872
+ * legal) won't get a 400.
873
+ */
874
+ function assertRemoteName(name) {
875
+ if (name.length === 0 || name.length > 200)
876
+ throw new InvalidBranchNameError(name);
877
+ if (!/^[A-Za-z0-9._/-]+$/.test(name))
878
+ throw new InvalidBranchNameError(name);
879
+ if (name.startsWith("-"))
880
+ throw new InvalidBranchNameError(name);
881
+ if (name.includes("..") || name.includes("@{"))
882
+ throw new InvalidBranchNameError(name);
883
+ }
884
+ //# sourceMappingURL=git-operations.js.map