@cuongtran001/kanna 0.39.2

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 (473) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +355 -0
  3. package/bin/kanna +9 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/abap-BdImnpbu.js +1 -0
  6. package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
  7. package/dist/client/assets/ada-bCR0ucgS.js +1 -0
  8. package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
  9. package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
  10. package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
  11. package/dist/client/assets/apache-Pmp26Uib.js +1 -0
  12. package/dist/client/assets/apex-D8_7TLub.js +1 -0
  13. package/dist/client/assets/apl-dKokRX4l.js +1 -0
  14. package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
  15. package/dist/client/assets/ara-BRHolxvo.js +1 -0
  16. package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
  17. package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
  18. package/dist/client/assets/astro-CbQHKStN.js +1 -0
  19. package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
  20. package/dist/client/assets/awk-DMzUqQB5.js +1 -0
  21. package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
  22. package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
  23. package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
  24. package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
  25. package/dist/client/assets/bat-BkioyH1T.js +1 -0
  26. package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
  27. package/dist/client/assets/berry-uYugtg8r.js +1 -0
  28. package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
  29. package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
  30. package/dist/client/assets/bird2-DPOp833l.js +1 -0
  31. package/dist/client/assets/blade-D4QpJJKB.js +1 -0
  32. package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  33. package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  34. package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  35. package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
  36. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  37. package/dist/client/assets/c3-eo99z4R2.js +1 -0
  38. package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
  39. package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
  40. package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  41. package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  42. package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  43. package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  44. package/dist/client/assets/clarity-D53aC0YG.js +1 -0
  45. package/dist/client/assets/clojure-P80f7IUj.js +1 -0
  46. package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
  47. package/dist/client/assets/cobol-nwyudZeR.js +1 -0
  48. package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
  49. package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
  50. package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
  51. package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
  52. package/dist/client/assets/coq-DkFqJrB1.js +1 -0
  53. package/dist/client/assets/cpp-CofmeUqb.js +1 -0
  54. package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
  55. package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
  56. package/dist/client/assets/css-DPfMkruS.js +1 -0
  57. package/dist/client/assets/csv-fuZLfV_i.js +1 -0
  58. package/dist/client/assets/cue-D82EKSYY.js +1 -0
  59. package/dist/client/assets/cypher-COkxafJQ.js +1 -0
  60. package/dist/client/assets/d-85-TOEBH.js +1 -0
  61. package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
  62. package/dist/client/assets/dart-CF10PKvl.js +1 -0
  63. package/dist/client/assets/dax-CEL-wOlO.js +1 -0
  64. package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
  65. package/dist/client/assets/diff-D97Zzqfu.js +1 -0
  66. package/dist/client/assets/docker-BcOcwvcX.js +1 -0
  67. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  68. package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
  69. package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
  70. package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
  71. package/dist/client/assets/edge-BkV0erSs.js +1 -0
  72. package/dist/client/assets/elixir-CDX3lj18.js +1 -0
  73. package/dist/client/assets/elm-DbKCFpqz.js +1 -0
  74. package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
  75. package/dist/client/assets/erb-B12qg9BL.js +1 -0
  76. package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
  77. package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
  78. package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
  79. package/dist/client/assets/fennel-BYunw83y.js +1 -0
  80. package/dist/client/assets/fish-BvzEVeQv.js +1 -0
  81. package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
  82. package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  83. package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
  84. package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
  85. package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
  86. package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
  87. package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
  88. package/dist/client/assets/genie-D0YGMca9.js +1 -0
  89. package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
  90. package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
  91. package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
  92. package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
  93. package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
  94. package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  95. package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  96. package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
  97. package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
  98. package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  99. package/dist/client/assets/gleam-BspZqrRM.js +1 -0
  100. package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
  101. package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
  102. package/dist/client/assets/glsl-DplSGwfg.js +1 -0
  103. package/dist/client/assets/gn-n2N0HUVH.js +1 -0
  104. package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
  105. package/dist/client/assets/go-CxLEBnE3.js +1 -0
  106. package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
  107. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  108. package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  109. package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  110. package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  111. package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  112. package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  113. package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  114. package/dist/client/assets/hack-CaT9iCJl.js +1 -0
  115. package/dist/client/assets/haml-B8DHNrY2.js +1 -0
  116. package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
  117. package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
  118. package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
  119. package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
  120. package/dist/client/assets/hjson-D5-asLiD.js +1 -0
  121. package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
  122. package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
  123. package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
  124. package/dist/client/assets/houston-DnULxvSX.js +1 -0
  125. package/dist/client/assets/html-GMplVEZG.js +1 -0
  126. package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
  127. package/dist/client/assets/http-jrhK8wxY.js +1 -0
  128. package/dist/client/assets/hurl-irOxFIW8.js +1 -0
  129. package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
  130. package/dist/client/assets/hy-DFXneXwc.js +1 -0
  131. package/dist/client/assets/imba-DGztddWO.js +1 -0
  132. package/dist/client/assets/index-Do7324M0.css +32 -0
  133. package/dist/client/assets/index-ktE9DLCD.js +2620 -0
  134. package/dist/client/assets/ini-BEwlwnbL.js +1 -0
  135. package/dist/client/assets/java-CylS5w8V.js +1 -0
  136. package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
  137. package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
  138. package/dist/client/assets/jison-wvAkD_A8.js +1 -0
  139. package/dist/client/assets/json-Cp-IABpG.js +1 -0
  140. package/dist/client/assets/json5-C9tS-k6U.js +1 -0
  141. package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
  142. package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
  143. package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
  144. package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
  145. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  146. package/dist/client/assets/julia-CxzCAyBv.js +1 -0
  147. package/dist/client/assets/just-Cw27pwNe.js +1 -0
  148. package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  149. package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  150. package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
  151. package/dist/client/assets/kdl-DV7GczEv.js +1 -0
  152. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  153. package/dist/client/assets/kusto-DZf3V79B.js +1 -0
  154. package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
  155. package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
  156. package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
  157. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  158. package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
  159. package/dist/client/assets/liquid-DYVedYrR.js +1 -0
  160. package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
  161. package/dist/client/assets/log-2UxHyX5q.js +1 -0
  162. package/dist/client/assets/logo-BtOb2qkB.js +1 -0
  163. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  164. package/dist/client/assets/luau-C-HG3fhB.js +1 -0
  165. package/dist/client/assets/make-CHLpvVh8.js +1 -0
  166. package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
  167. package/dist/client/assets/marko-CnJfTvn9.js +1 -0
  168. package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
  169. package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
  170. package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  171. package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
  172. package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  173. package/dist/client/assets/matlab-D7o27uSR.js +1 -0
  174. package/dist/client/assets/mdc-BMNejdWA.js +1 -0
  175. package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
  176. package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
  177. package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
  178. package/dist/client/assets/min-light-CTRr51gU.js +1 -0
  179. package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
  180. package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
  181. package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
  182. package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
  183. package/dist/client/assets/move-IF9eRakj.js +1 -0
  184. package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
  185. package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
  186. package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
  187. package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
  188. package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
  189. package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
  190. package/dist/client/assets/nim-CVrawwO9.js +1 -0
  191. package/dist/client/assets/nix-CwoSXNpI.js +1 -0
  192. package/dist/client/assets/nord-Ddv68eIx.js +1 -0
  193. package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
  194. package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
  195. package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
  196. package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
  197. package/dist/client/assets/odin-BBf5iR-q.js +1 -0
  198. package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  199. package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
  200. package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
  201. package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
  202. package/dist/client/assets/perl-C0TMdlhV.js +1 -0
  203. package/dist/client/assets/php-Dhbhpdrm.js +1 -0
  204. package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
  205. package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
  206. package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
  207. package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
  208. package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
  209. package/dist/client/assets/po-BTJTHyun.js +1 -0
  210. package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
  211. package/dist/client/assets/polar-C0HS_06l.js +1 -0
  212. package/dist/client/assets/postcss-CXtECtnM.js +1 -0
  213. package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
  214. package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
  215. package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
  216. package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
  217. package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
  218. package/dist/client/assets/pug-CGlum2m_.js +1 -0
  219. package/dist/client/assets/puppet-BMWR74SV.js +1 -0
  220. package/dist/client/assets/purescript-CklMAg4u.js +1 -0
  221. package/dist/client/assets/python-B6aJPvgy.js +1 -0
  222. package/dist/client/assets/qml-3beO22l8.js +1 -0
  223. package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
  224. package/dist/client/assets/qss-IeuSbFQv.js +1 -0
  225. package/dist/client/assets/r-Dspwwk_N.js +1 -0
  226. package/dist/client/assets/racket-BqYA7rlc.js +1 -0
  227. package/dist/client/assets/raku-DXvB9xmW.js +1 -0
  228. package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
  229. package/dist/client/assets/red-bN70gL4F.js +1 -0
  230. package/dist/client/assets/reg-C-SQnVFl.js +1 -0
  231. package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
  232. package/dist/client/assets/rel-C3B-1QV4.js +1 -0
  233. package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
  234. package/dist/client/assets/ron-D8l8udqQ.js +1 -0
  235. package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  236. package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  237. package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
  238. package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
  239. package/dist/client/assets/rst-BrH8l1NY.js +1 -0
  240. package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
  241. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  242. package/dist/client/assets/sas-cz2c8ADy.js +1 -0
  243. package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
  244. package/dist/client/assets/scala-C151Ov-r.js +1 -0
  245. package/dist/client/assets/scheme-C98Dy4si.js +1 -0
  246. package/dist/client/assets/scss-OYdSNvt2.js +1 -0
  247. package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
  248. package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
  249. package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
  250. package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
  251. package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
  252. package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
  253. package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
  254. package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
  255. package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
  256. package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
  257. package/dist/client/assets/solidity-rGO070M0.js +1 -0
  258. package/dist/client/assets/soy-Brmx7dQM.js +1 -0
  259. package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
  260. package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
  261. package/dist/client/assets/sql-BLtJtn59.js +1 -0
  262. package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
  263. package/dist/client/assets/stata-BH5u7GGu.js +1 -0
  264. package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
  265. package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
  266. package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
  267. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  268. package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
  269. package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
  270. package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
  271. package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
  272. package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
  273. package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
  274. package/dist/client/assets/templ-P3uqSqPl.js +1 -0
  275. package/dist/client/assets/terraform-BETggiCN.js +1 -0
  276. package/dist/client/assets/tex-idrVyKtj.js +1 -0
  277. package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
  278. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  279. package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
  280. package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
  281. package/dist/client/assets/tsx-COt5Ahok.js +1 -0
  282. package/dist/client/assets/turtle-BsS91CYL.js +1 -0
  283. package/dist/client/assets/twig-DNn4PbVi.js +1 -0
  284. package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
  285. package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
  286. package/dist/client/assets/typst-DHCkPAjA.js +1 -0
  287. package/dist/client/assets/v-BcVCzyr7.js +1 -0
  288. package/dist/client/assets/vala-CsfeWuGM.js +1 -0
  289. package/dist/client/assets/vb-D17OF-Vu.js +1 -0
  290. package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
  291. package/dist/client/assets/vesper-DU1UobuO.js +1 -0
  292. package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
  293. package/dist/client/assets/viml-CJc9bBzg.js +1 -0
  294. package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
  295. package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
  296. package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
  297. package/dist/client/assets/vue-DN_0RTcg.js +1 -0
  298. package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
  299. package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
  300. package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
  301. package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
  302. package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
  303. package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
  304. package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
  305. package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
  306. package/dist/client/assets/wit-5i3qLPDT.js +1 -0
  307. package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
  308. package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
  309. package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
  310. package/dist/client/assets/yaml-Buea-lGh.js +1 -0
  311. package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
  312. package/dist/client/assets/zig-VOosw3JB.js +1 -0
  313. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  314. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  315. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  316. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  317. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  318. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  319. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  320. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  321. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  322. package/dist/client/editor-icons/cursor.png +0 -0
  323. package/dist/client/editor-icons/custom.png +0 -0
  324. package/dist/client/editor-icons/default-app.png +0 -0
  325. package/dist/client/editor-icons/finder.png +0 -0
  326. package/dist/client/editor-icons/preview.png +0 -0
  327. package/dist/client/editor-icons/terminal.png +0 -0
  328. package/dist/client/editor-icons/windsurf.png +0 -0
  329. package/dist/client/editor-icons/xcode.png +0 -0
  330. package/dist/client/favicon.png +0 -0
  331. package/dist/client/fonts/body-medium.woff2 +0 -0
  332. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  333. package/dist/client/fonts/body-regular.woff2 +0 -0
  334. package/dist/client/fonts/body-semibold.woff2 +0 -0
  335. package/dist/client/icon-192.png +0 -0
  336. package/dist/client/icon-512.png +0 -0
  337. package/dist/client/icon-maskable-512.png +0 -0
  338. package/dist/client/icon.svg +4 -0
  339. package/dist/client/index.html +34 -0
  340. package/dist/client/manifest.webmanifest +46 -0
  341. package/dist/client/screenshot-light.png +0 -0
  342. package/dist/client/screenshot.png +0 -0
  343. package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  344. package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  345. package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  346. package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
  347. package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
  348. package/dist/export-viewer/editor-icons/cursor.png +0 -0
  349. package/dist/export-viewer/editor-icons/custom.png +0 -0
  350. package/dist/export-viewer/editor-icons/default-app.png +0 -0
  351. package/dist/export-viewer/editor-icons/finder.png +0 -0
  352. package/dist/export-viewer/editor-icons/preview.png +0 -0
  353. package/dist/export-viewer/editor-icons/terminal.png +0 -0
  354. package/dist/export-viewer/editor-icons/windsurf.png +0 -0
  355. package/dist/export-viewer/editor-icons/xcode.png +0 -0
  356. package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
  357. package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
  358. package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
  359. package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
  360. package/dist/export-viewer/index.html +14 -0
  361. package/package.json +99 -0
  362. package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
  363. package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
  364. package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
  365. package/src/server/agent.test.ts +2369 -0
  366. package/src/server/agent.ts +1927 -0
  367. package/src/server/analytics.test.ts +313 -0
  368. package/src/server/analytics.ts +131 -0
  369. package/src/server/app-settings.test.ts +233 -0
  370. package/src/server/app-settings.ts +548 -0
  371. package/src/server/auth.test.ts +329 -0
  372. package/src/server/auth.ts +204 -0
  373. package/src/server/auto-continue/e2e.test.ts +215 -0
  374. package/src/server/auto-continue/events.test.ts +30 -0
  375. package/src/server/auto-continue/events.ts +35 -0
  376. package/src/server/auto-continue/limit-detector.test.ts +153 -0
  377. package/src/server/auto-continue/limit-detector.ts +159 -0
  378. package/src/server/auto-continue/read-model.test.ts +109 -0
  379. package/src/server/auto-continue/read-model.ts +83 -0
  380. package/src/server/auto-continue/schedule-manager.test.ts +155 -0
  381. package/src/server/auto-continue/schedule-manager.ts +116 -0
  382. package/src/server/claude-session-importer.test.ts +214 -0
  383. package/src/server/claude-session-importer.ts +187 -0
  384. package/src/server/claude-session-mapper.test.ts +88 -0
  385. package/src/server/claude-session-mapper.ts +106 -0
  386. package/src/server/claude-session-parser.test.ts +38 -0
  387. package/src/server/claude-session-parser.ts +67 -0
  388. package/src/server/claude-session-scanner.test.ts +49 -0
  389. package/src/server/claude-session-scanner.ts +24 -0
  390. package/src/server/claude-session-types.ts +61 -0
  391. package/src/server/cli-runtime.test.ts +523 -0
  392. package/src/server/cli-runtime.ts +405 -0
  393. package/src/server/cli-supervisor.ts +102 -0
  394. package/src/server/cli.ts +64 -0
  395. package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
  396. package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
  397. package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
  398. package/src/server/cloudflare-tunnel/detector.ts +44 -0
  399. package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
  400. package/src/server/cloudflare-tunnel/events.test.ts +43 -0
  401. package/src/server/cloudflare-tunnel/events.ts +31 -0
  402. package/src/server/cloudflare-tunnel/gateway.ts +143 -0
  403. package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
  404. package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
  405. package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
  406. package/src/server/cloudflare-tunnel/read-model.ts +80 -0
  407. package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
  408. package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
  409. package/src/server/codex-app-server-protocol.ts +487 -0
  410. package/src/server/codex-app-server.test.ts +1816 -0
  411. package/src/server/codex-app-server.ts +1475 -0
  412. package/src/server/diff-store.test.ts +737 -0
  413. package/src/server/diff-store.ts +2199 -0
  414. package/src/server/discovery.test.ts +211 -0
  415. package/src/server/discovery.ts +301 -0
  416. package/src/server/event-store.test.ts +797 -0
  417. package/src/server/event-store.ts +1421 -0
  418. package/src/server/events.ts +217 -0
  419. package/src/server/external-open.test.ts +112 -0
  420. package/src/server/external-open.ts +345 -0
  421. package/src/server/generate-commit-message.test.ts +79 -0
  422. package/src/server/generate-commit-message.ts +126 -0
  423. package/src/server/generate-title.ts +76 -0
  424. package/src/server/harness-types.ts +19 -0
  425. package/src/server/keybindings.test.ts +144 -0
  426. package/src/server/keybindings.ts +178 -0
  427. package/src/server/llm-provider.test.ts +134 -0
  428. package/src/server/llm-provider.ts +207 -0
  429. package/src/server/machine-name.ts +22 -0
  430. package/src/server/paths-route.test.ts +64 -0
  431. package/src/server/paths.ts +35 -0
  432. package/src/server/process-utils.test.ts +12 -0
  433. package/src/server/process-utils.ts +47 -0
  434. package/src/server/project-paths.test.ts +95 -0
  435. package/src/server/project-paths.ts +191 -0
  436. package/src/server/provider-catalog.test.ts +69 -0
  437. package/src/server/provider-catalog.ts +87 -0
  438. package/src/server/quick-response.test.ts +440 -0
  439. package/src/server/quick-response.ts +300 -0
  440. package/src/server/read-models.test.ts +509 -0
  441. package/src/server/read-models.ts +230 -0
  442. package/src/server/restart.test.ts +27 -0
  443. package/src/server/restart.ts +30 -0
  444. package/src/server/server.ts +616 -0
  445. package/src/server/share.test.ts +180 -0
  446. package/src/server/share.ts +150 -0
  447. package/src/server/standalone-export.test.ts +224 -0
  448. package/src/server/standalone-export.ts +419 -0
  449. package/src/server/terminal-manager.test.ts +315 -0
  450. package/src/server/terminal-manager.ts +350 -0
  451. package/src/server/test-helpers/async-event-queue.ts +52 -0
  452. package/src/server/test-helpers/wait-for.ts +14 -0
  453. package/src/server/title-generation.live.test.ts +44 -0
  454. package/src/server/update-manager.test.ts +158 -0
  455. package/src/server/update-manager.ts +222 -0
  456. package/src/server/update-strategy.test.ts +237 -0
  457. package/src/server/update-strategy.ts +241 -0
  458. package/src/server/uploads.test.ts +292 -0
  459. package/src/server/uploads.ts +131 -0
  460. package/src/server/ws-router.test.ts +2292 -0
  461. package/src/server/ws-router.ts +1465 -0
  462. package/src/shared/analytics.ts +30 -0
  463. package/src/shared/branding.test.ts +31 -0
  464. package/src/shared/branding.ts +77 -0
  465. package/src/shared/dev-ports.test.ts +113 -0
  466. package/src/shared/dev-ports.ts +134 -0
  467. package/src/shared/ports.ts +2 -0
  468. package/src/shared/protocol.ts +257 -0
  469. package/src/shared/share.ts +27 -0
  470. package/src/shared/tools.test.ts +164 -0
  471. package/src/shared/tools.ts +327 -0
  472. package/src/shared/types.test.ts +25 -0
  473. package/src/shared/types.ts +1088 -0
@@ -0,0 +1,2199 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import type {
6
+ BranchMetadata,
7
+ ChatBranchHistoryEntry,
8
+ ChatBranchHistorySnapshot,
9
+ ChatBranchListEntry,
10
+ ChatBranchListResult,
11
+ ChatCheckoutBranchResult,
12
+ ChatCreateBranchResult,
13
+ ChatDiffFile,
14
+ ChatDiffSnapshot,
15
+ BranchActionSuccess,
16
+ BranchActionFailure,
17
+ GitHubPublishInfo,
18
+ GitHubRepoAvailabilityResult,
19
+ ChatMergeBranchResult,
20
+ ChatMergePreviewResult,
21
+ ChatSyncResult,
22
+ DiffCommitMode,
23
+ DiffCommitResult,
24
+ UpstreamStatus,
25
+ } from "../shared/types"
26
+ import { generateCommitMessageDetailed } from "./generate-commit-message"
27
+ import { inferProjectFileContentType } from "./uploads"
28
+
29
+ interface StoredChatDiffState extends BranchMetadata, UpstreamStatus {
30
+ status: ChatDiffSnapshot["status"]
31
+ files: ChatDiffFile[]
32
+ branchHistory: ChatBranchHistorySnapshot
33
+ }
34
+
35
+ function createEmptyState(): StoredChatDiffState {
36
+ return {
37
+ status: "unknown",
38
+ branchName: undefined,
39
+ defaultBranchName: undefined,
40
+ hasOriginRemote: undefined,
41
+ originRepoSlug: undefined,
42
+ hasUpstream: undefined,
43
+ aheadCount: undefined,
44
+ behindCount: undefined,
45
+ lastFetchedAt: undefined,
46
+ files: [],
47
+ branchHistory: { entries: [] },
48
+ }
49
+ }
50
+
51
+ function branchMetadataEqual(left: BranchMetadata, right: BranchMetadata) {
52
+ return left.branchName === right.branchName
53
+ && left.defaultBranchName === right.defaultBranchName
54
+ && left.hasOriginRemote === right.hasOriginRemote
55
+ && left.originRepoSlug === right.originRepoSlug
56
+ && left.hasUpstream === right.hasUpstream
57
+ }
58
+
59
+ function upstreamStatusEqual(left: UpstreamStatus, right: UpstreamStatus) {
60
+ return left.aheadCount === right.aheadCount
61
+ && left.behindCount === right.behindCount
62
+ && left.lastFetchedAt === right.lastFetchedAt
63
+ }
64
+
65
+ function branchHistoryEqual(left: ChatBranchHistorySnapshot, right: ChatBranchHistorySnapshot) {
66
+ if (left.entries.length !== right.entries.length) return false
67
+ return left.entries.every((entry, index) => {
68
+ const other = right.entries[index]
69
+ return Boolean(other)
70
+ && entry.sha === other.sha
71
+ && entry.summary === other.summary
72
+ && entry.description === other.description
73
+ && entry.authorName === other.authorName
74
+ && entry.authoredAt === other.authoredAt
75
+ && entry.githubUrl === other.githubUrl
76
+ && entry.tags.length === other.tags.length
77
+ && entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex])
78
+ })
79
+ }
80
+
81
+ function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState) {
82
+ if (!left) {
83
+ return right.status === "unknown" && right.files.length === 0
84
+ }
85
+ if (left.status !== right.status) return false
86
+ if (!branchMetadataEqual(left, right)) return false
87
+ if (!upstreamStatusEqual(left, right)) return false
88
+ if (left.files.length !== right.files.length) return false
89
+ if (!branchHistoryEqual(left.branchHistory, right.branchHistory)) return false
90
+ return left.files.every((file, index) => {
91
+ const other = right.files[index]
92
+ return Boolean(other)
93
+ && file.path === other.path
94
+ && file.changeType === other.changeType
95
+ && file.isUntracked === other.isUntracked
96
+ && file.additions === other.additions
97
+ && file.deletions === other.deletions
98
+ && file.patchDigest === other.patchDigest
99
+ && file.mimeType === other.mimeType
100
+ && file.size === other.size
101
+ })
102
+ }
103
+
104
+ interface DirtyPathEntry {
105
+ path: string
106
+ previousPath?: string
107
+ changeType: ChatDiffFile["changeType"]
108
+ isUntracked: boolean
109
+ }
110
+
111
+ type SelectedBranch =
112
+ | { kind: "local"; name: string }
113
+ | { kind: "remote"; name: string; remoteRef: string }
114
+ | {
115
+ kind: "pull_request"
116
+ name: string
117
+ prNumber: number
118
+ headRefName: string
119
+ headRepoCloneUrl?: string
120
+ isCrossRepository?: boolean
121
+ remoteRef?: string
122
+ }
123
+
124
+ async function runGit(args: string[], cwd: string) {
125
+ const process = Bun.spawn(["git", "-C", cwd, ...args], {
126
+ stdout: "pipe",
127
+ stderr: "pipe",
128
+ })
129
+ const [stdout, stderr, exitCode] = await Promise.all([
130
+ new Response(process.stdout).text(),
131
+ new Response(process.stderr).text(),
132
+ process.exited,
133
+ ])
134
+
135
+ return {
136
+ stdout,
137
+ stderr,
138
+ exitCode,
139
+ }
140
+ }
141
+
142
+ async function runCommand(args: string[]) {
143
+ const process = Bun.spawn(args, {
144
+ stdout: "pipe",
145
+ stderr: "pipe",
146
+ })
147
+ const [stdout, stderr, exitCode] = await Promise.all([
148
+ new Response(process.stdout).text(),
149
+ new Response(process.stderr).text(),
150
+ process.exited,
151
+ ])
152
+
153
+ return {
154
+ stdout,
155
+ stderr,
156
+ exitCode,
157
+ }
158
+ }
159
+
160
+ function formatGitFailure(result: Awaited<ReturnType<typeof runGit>>) {
161
+ return [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n")
162
+ }
163
+
164
+ function summarizeGitFailure(detail: string, fallback: string) {
165
+ return detail
166
+ .split(/\r?\n/u)
167
+ .map((line) => line.trim())
168
+ .find((line) => line.length > 0)
169
+ ?? fallback
170
+ }
171
+
172
+ function createCommitFailure(mode: DiffCommitMode, detail: string): DiffCommitResult {
173
+ const normalized = detail.toLowerCase()
174
+ let title = "Commit failed"
175
+ let message = summarizeGitFailure(detail, "Git could not create the commit.")
176
+
177
+ if (normalized.includes("ignored by one of your .gitignore files")) {
178
+ title = "Ignored files cannot be staged"
179
+ message = "One or more selected paths are ignored by .gitignore. Unignore them or remove them from the commit selection."
180
+ }
181
+
182
+ return {
183
+ ok: false,
184
+ mode,
185
+ phase: "commit",
186
+ title,
187
+ message,
188
+ detail,
189
+ }
190
+ }
191
+
192
+ function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged: boolean): DiffCommitResult {
193
+ const normalized = detail.toLowerCase()
194
+ let title = "Push failed"
195
+ let message = summarizeGitFailure(detail, "Git could not push the commit.")
196
+
197
+ if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
198
+ title = "Branch is not up to date"
199
+ message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
200
+ } else if (normalized.includes("does not appear to be a git repository")) {
201
+ title = "No origin remote configured"
202
+ message = "This repository does not have an origin remote configured."
203
+ } else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
204
+ title = "No upstream branch configured"
205
+ message = "This branch does not have an upstream remote branch configured yet."
206
+ } else if (normalized.includes("merge conflict") || normalized.includes("resolve conflicts")) {
207
+ title = "Merge conflicts need resolution"
208
+ message = "Git reported conflicts while preparing the push. Resolve them, then try again."
209
+ } else if (normalized.includes("permission denied") || normalized.includes("authentication failed") || normalized.includes("could not read from remote repository")) {
210
+ title = "Remote authentication failed"
211
+ message = "Git could not authenticate with the remote repository."
212
+ }
213
+
214
+ return {
215
+ ok: false,
216
+ mode,
217
+ phase: "push",
218
+ title,
219
+ message,
220
+ detail,
221
+ localCommitCreated: true,
222
+ snapshotChanged,
223
+ }
224
+ }
225
+
226
+ function createSyncPushFailure(detail: string, snapshotChanged: boolean): ChatSyncResult {
227
+ const normalized = detail.toLowerCase()
228
+ let title = "Push failed"
229
+ let message = summarizeGitFailure(detail, "Git could not push this branch.")
230
+
231
+ if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
232
+ title = "Branch is not up to date"
233
+ message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
234
+ } else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
235
+ title = "No upstream branch configured"
236
+ message = "This branch does not have an upstream remote branch configured yet."
237
+ } else if (normalized.includes("merge conflict") || normalized.includes("resolve conflicts")) {
238
+ title = "Merge conflicts need resolution"
239
+ message = "Git reported conflicts while preparing the push. Resolve them, then try again."
240
+ } else if (normalized.includes("permission denied") || normalized.includes("authentication failed") || normalized.includes("could not read from remote repository")) {
241
+ title = "Remote authentication failed"
242
+ message = "Git could not authenticate with the remote repository."
243
+ }
244
+
245
+ return {
246
+ ok: false,
247
+ action: "push",
248
+ title,
249
+ message,
250
+ detail,
251
+ snapshotChanged,
252
+ }
253
+ }
254
+
255
+ async function resolveRepo(projectPath: string): Promise<{ repoRoot: string; baseCommit: string | null } | null> {
256
+ const topLevel = await runGit(["rev-parse", "--show-toplevel"], projectPath)
257
+ if (topLevel.exitCode !== 0) {
258
+ return null
259
+ }
260
+
261
+ const repoRoot = topLevel.stdout.trim()
262
+ const head = await runGit(["rev-parse", "--verify", "HEAD"], repoRoot)
263
+ return {
264
+ repoRoot,
265
+ baseCommit: head.exitCode === 0 ? head.stdout.trim() : null,
266
+ }
267
+ }
268
+
269
+ async function getBranchName(repoRoot: string) {
270
+ const symbolicRef = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot)
271
+ if (symbolicRef.exitCode === 0) {
272
+ return symbolicRef.stdout.trim()
273
+ }
274
+
275
+ const revParse = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot)
276
+ if (revParse.exitCode === 0) {
277
+ return revParse.stdout.trim()
278
+ }
279
+
280
+ return undefined
281
+ }
282
+
283
+ async function hasUpstreamBranch(repoRoot: string) {
284
+ const upstream = await runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)
285
+ return upstream.exitCode === 0 && upstream.stdout.trim().length > 0
286
+ }
287
+
288
+ async function getLastFetchedAt(repoRoot: string) {
289
+ const gitDirResult = await runGit(["rev-parse", "--git-dir"], repoRoot)
290
+ if (gitDirResult.exitCode !== 0) {
291
+ return undefined
292
+ }
293
+
294
+ const gitDir = gitDirResult.stdout.trim()
295
+ const fetchHeadPath = path.resolve(repoRoot, gitDir, "FETCH_HEAD")
296
+ try {
297
+ const fetchHeadStat = await stat(fetchHeadPath)
298
+ return fetchHeadStat.mtime.toISOString()
299
+ } catch {
300
+ return undefined
301
+ }
302
+ }
303
+
304
+ async function getUpstreamStatusCounts(repoRoot: string) {
305
+ const result = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], repoRoot)
306
+ if (result.exitCode !== 0) {
307
+ return { aheadCount: undefined, behindCount: undefined }
308
+ }
309
+
310
+ const [aheadRaw, behindRaw] = result.stdout.trim().split(/\s+/u)
311
+ const aheadCount = Number.parseInt(aheadRaw ?? "", 10)
312
+ const behindCount = Number.parseInt(behindRaw ?? "", 10)
313
+ return {
314
+ aheadCount: Number.isFinite(aheadCount) ? aheadCount : undefined,
315
+ behindCount: Number.isFinite(behindCount) ? behindCount : undefined,
316
+ }
317
+ }
318
+
319
+ async function getOriginRemoteUrl(repoRoot: string) {
320
+ const result = await runGit(["remote", "get-url", "origin"], repoRoot)
321
+ if (result.exitCode !== 0) {
322
+ return null
323
+ }
324
+ const remoteUrl = result.stdout.trim()
325
+ return remoteUrl.length > 0 ? remoteUrl : null
326
+ }
327
+
328
+ async function getGitHubRemoteSlugs(repoRoot: string) {
329
+ const remotesResult = await runGit(["remote"], repoRoot)
330
+ if (remotesResult.exitCode !== 0) {
331
+ return new Map<string, string>()
332
+ }
333
+
334
+ const remoteNames = remotesResult.stdout
335
+ .split(/\r?\n/u)
336
+ .map((line) => line.trim())
337
+ .filter(Boolean)
338
+
339
+ const remoteSlugEntries = await Promise.all(remoteNames.map(async (remoteName) => {
340
+ const remoteUrlResult = await runGit(["remote", "get-url", remoteName], repoRoot)
341
+ if (remoteUrlResult.exitCode !== 0) {
342
+ return null
343
+ }
344
+ const repoSlug = extractGitHubRepoSlug(remoteUrlResult.stdout.trim())
345
+ return repoSlug ? [remoteName, repoSlug.toLowerCase()] as const : null
346
+ }))
347
+
348
+ return new Map(remoteSlugEntries.filter((entry): entry is readonly [string, string] => Boolean(entry)))
349
+ }
350
+
351
+ async function getLocalBranchNames(repoRoot: string) {
352
+ const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoRoot)
353
+ if (result.exitCode !== 0) {
354
+ throw new Error(result.stderr.trim() || "Failed to list local branches")
355
+ }
356
+ return result.stdout
357
+ .split(/\r?\n/u)
358
+ .map((line) => line.trim())
359
+ .filter(Boolean)
360
+ .sort((left, right) => left.localeCompare(right))
361
+ }
362
+
363
+ async function getRemoteBranchNames(repoRoot: string) {
364
+ const result = await runGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes"], repoRoot)
365
+ if (result.exitCode !== 0) {
366
+ throw new Error(result.stderr.trim() || "Failed to list remote branches")
367
+ }
368
+ return result.stdout
369
+ .split(/\r?\n/u)
370
+ .map((line) => line.trim())
371
+ .filter((line) => line.length > 0 && !line.endsWith("/HEAD"))
372
+ .sort((left, right) => left.localeCompare(right))
373
+ }
374
+
375
+ async function getBranchUpdatedAtMap(repoRoot: string, refPrefix: "refs/heads" | "refs/remotes") {
376
+ const result = await runGit(
377
+ ["for-each-ref", "--format=%(refname:short)\t%(committerdate:iso-strict)", refPrefix],
378
+ repoRoot
379
+ )
380
+ if (result.exitCode !== 0) {
381
+ throw new Error(result.stderr.trim() || "Failed to read branch update times")
382
+ }
383
+
384
+ const entries = new Map<string, string>()
385
+ for (const line of result.stdout.split(/\r?\n/u)) {
386
+ const trimmed = line.trim()
387
+ if (!trimmed) continue
388
+ const [name, updatedAt] = trimmed.split("\t")
389
+ if (!name || !updatedAt || (refPrefix === "refs/remotes" && name.endsWith("/HEAD"))) {
390
+ continue
391
+ }
392
+ entries.set(name, updatedAt)
393
+ }
394
+ return entries
395
+ }
396
+
397
+ async function resolveDefaultBranchName(repoRoot: string) {
398
+ const originHead = await runGit(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], repoRoot)
399
+ if (originHead.exitCode === 0) {
400
+ const ref = originHead.stdout.trim()
401
+ if (ref.startsWith("origin/")) {
402
+ return ref.slice("origin/".length)
403
+ }
404
+ }
405
+
406
+ const localBranches = await getLocalBranchNames(repoRoot)
407
+ if (localBranches.includes("main")) return "main"
408
+ if (localBranches.includes("master")) return "master"
409
+ return (await getBranchName(repoRoot)) ?? localBranches[0] ?? undefined
410
+ }
411
+
412
+ async function getRecentBranchNames(repoRoot: string) {
413
+ const result = await runGit(["reflog", "--format=%gs", "--max-count=100", "HEAD"], repoRoot)
414
+ if (result.exitCode !== 0) {
415
+ return []
416
+ }
417
+
418
+ const recent: string[] = []
419
+ const seen = new Set<string>()
420
+ for (const line of result.stdout.split(/\r?\n/u)) {
421
+ const match = /checkout: moving from .* to (?<branch>.+)$/u.exec(line.trim())
422
+ const branch = match?.groups?.branch?.trim()
423
+ if (!branch || branch === "HEAD" || branch.startsWith("refs/")) {
424
+ continue
425
+ }
426
+ if (seen.has(branch)) continue
427
+ seen.add(branch)
428
+ recent.push(branch)
429
+ }
430
+ return recent
431
+ }
432
+
433
+ async function resolveSelectedBranchRef(repoRoot: string, branch: SelectedBranch) {
434
+ if (branch.kind === "local") {
435
+ const localBranchNames = await getLocalBranchNames(repoRoot)
436
+ if (!localBranchNames.includes(branch.name)) {
437
+ throw new Error(`Local branch not found: ${branch.name}`)
438
+ }
439
+ return {
440
+ ref: branch.name,
441
+ displayName: branch.name,
442
+ branchName: branch.name,
443
+ }
444
+ }
445
+
446
+ if (branch.kind === "remote") {
447
+ const remoteRef = branch.remoteRef.trim()
448
+ const remoteBranchNames = await getRemoteBranchNames(repoRoot)
449
+ if (!remoteBranchNames.includes(remoteRef)) {
450
+ throw new Error(`Remote branch not found: ${remoteRef}`)
451
+ }
452
+ return {
453
+ ref: remoteRef,
454
+ displayName: remoteRef,
455
+ branchName: branch.name,
456
+ }
457
+ }
458
+
459
+ const localBranchNames = await getLocalBranchNames(repoRoot)
460
+ if (localBranchNames.includes(branch.name)) {
461
+ return {
462
+ ref: branch.name,
463
+ displayName: `PR #${branch.prNumber}`,
464
+ branchName: branch.name,
465
+ }
466
+ }
467
+
468
+ const remoteRef = branch.remoteRef?.trim()
469
+ if (remoteRef) {
470
+ const remoteBranchNames = await getRemoteBranchNames(repoRoot)
471
+ if (remoteBranchNames.includes(remoteRef)) {
472
+ return {
473
+ ref: remoteRef,
474
+ displayName: `PR #${branch.prNumber}`,
475
+ branchName: branch.headRefName || branch.name,
476
+ }
477
+ }
478
+ }
479
+
480
+ if (branch.isCrossRepository) {
481
+ throw new Error("This pull request branch is not available locally yet. Check it out first before merging.")
482
+ }
483
+
484
+ throw new Error(`Pull request branch not found: ${branch.headRefName || branch.name}`)
485
+ }
486
+
487
+ async function getMergeCommitCount(repoRoot: string, sourceRef: string) {
488
+ const result = await runGit(["rev-list", "--count", `HEAD..${sourceRef}`], repoRoot)
489
+ if (result.exitCode !== 0) {
490
+ throw new Error(result.stderr.trim() || "Failed to calculate merge commit count")
491
+ }
492
+
493
+ const commitCount = Number.parseInt(result.stdout.trim(), 10)
494
+ return Number.isFinite(commitCount) ? commitCount : 0
495
+ }
496
+
497
+ async function predictMergeConflicts(repoRoot: string, sourceRef: string) {
498
+ // Try the newer `git merge-tree --write-tree` form (requires Git 2.38+).
499
+ const newResult = await runGit(["merge-tree", "--write-tree", "--messages", "HEAD", sourceRef], repoRoot)
500
+
501
+ // Exit code 129 means the --write-tree flag is not supported (Git < 2.38).
502
+ // Fall back to the legacy three-argument form.
503
+ if (newResult.exitCode !== 129) {
504
+ const output = `${newResult.stdout}\n${newResult.stderr}`.trim()
505
+
506
+ if (newResult.exitCode === 0) {
507
+ return { hasConflicts: false }
508
+ }
509
+
510
+ const normalizedOutput = output.toLowerCase()
511
+ if (newResult.exitCode === 1 || normalizedOutput.includes("conflict")) {
512
+ return {
513
+ hasConflicts: true,
514
+ detail: output || "Git reported merge conflicts for this branch pair.",
515
+ }
516
+ }
517
+
518
+ throw new Error(output || "Failed to analyze merge conflicts")
519
+ }
520
+
521
+ // Legacy fallback: `git merge-tree <base> HEAD <source>` (Git < 2.38).
522
+ const baseResult = await runGit(["merge-base", "HEAD", sourceRef], repoRoot)
523
+ if (baseResult.exitCode !== 0) {
524
+ throw new Error(baseResult.stderr.trim() || "Failed to find merge base")
525
+ }
526
+ const baseTree = baseResult.stdout.trim()
527
+
528
+ const legacyResult = await runGit(["merge-tree", baseTree, "HEAD", sourceRef], repoRoot)
529
+ const legacyOutput = `${legacyResult.stdout}\n${legacyResult.stderr}`.trim()
530
+
531
+ if (legacyResult.exitCode !== 0) {
532
+ throw new Error(legacyOutput || "Failed to analyze merge conflicts")
533
+ }
534
+
535
+ // In the legacy form, conflict markers (<<<<<<) appear in the output when there are conflicts.
536
+ if (legacyOutput.includes("<<<<<<<") || legacyOutput.toLowerCase().includes("conflict")) {
537
+ return {
538
+ hasConflicts: true,
539
+ detail: legacyOutput || "Git reported merge conflicts for this branch pair.",
540
+ }
541
+ }
542
+
543
+ return { hasConflicts: false }
544
+ }
545
+
546
+ export function extractGitHubRepoSlug(remoteUrl: string | null | undefined) {
547
+ if (!remoteUrl) return null
548
+
549
+ const sshMatch = /^git@github\.com:(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
550
+ if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
551
+ return `${sshMatch.groups.owner}/${sshMatch.groups.repo}`
552
+ }
553
+
554
+ const sshProtocolMatch = /^ssh:\/\/git@github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
555
+ if (sshProtocolMatch?.groups?.owner && sshProtocolMatch.groups.repo) {
556
+ return `${sshProtocolMatch.groups.owner}/${sshProtocolMatch.groups.repo}`
557
+ }
558
+
559
+ const httpsMatch = /^https?:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/u.exec(remoteUrl)
560
+ if (httpsMatch?.groups?.owner && httpsMatch.groups.repo) {
561
+ return `${httpsMatch.groups.owner}/${httpsMatch.groups.repo}`
562
+ }
563
+
564
+ return null
565
+ }
566
+
567
+ interface GitHubPullRequestResponseItem {
568
+ number: number
569
+ title: string
570
+ head?: {
571
+ ref?: string
572
+ label?: string
573
+ repo?: {
574
+ clone_url?: string
575
+ full_name?: string
576
+ } | null
577
+ }
578
+ base?: {
579
+ ref?: string
580
+ }
581
+ }
582
+
583
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>
584
+
585
+ type GitHubCliApiLike = (path: string) => Promise<GitHubPullRequestResponseItem[] | null>
586
+
587
+ interface FetchGitHubPullRequestsDeps {
588
+ fetchImpl?: FetchLike
589
+ ghApiImpl?: GitHubCliApiLike
590
+ }
591
+
592
+ async function fetchGitHubPullRequestsViaGh(path: string): Promise<GitHubPullRequestResponseItem[] | null> {
593
+ const result = await runCommand([
594
+ "gh",
595
+ "api",
596
+ "-H",
597
+ "Accept: application/vnd.github+json",
598
+ path,
599
+ ])
600
+ if (result.exitCode !== 0) {
601
+ return null
602
+ }
603
+
604
+ const json = JSON.parse(result.stdout)
605
+ return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
606
+ }
607
+
608
+ export async function fetchGitHubPullRequests(
609
+ repoSlug: string,
610
+ deps: FetchLike | FetchGitHubPullRequestsDeps = fetch
611
+ ): Promise<GitHubPullRequestResponseItem[]> {
612
+ const fetchImpl = typeof deps === "function" ? deps : (deps.fetchImpl ?? fetch)
613
+ const ghApiImpl = typeof deps === "function" ? fetchGitHubPullRequestsViaGh : (deps.ghApiImpl ?? fetchGitHubPullRequestsViaGh)
614
+ const ghPath = `repos/${repoSlug}/pulls?state=open&per_page=50`
615
+
616
+ try {
617
+ const ghPulls = await ghApiImpl(ghPath)
618
+ if (ghPulls) {
619
+ return ghPulls
620
+ }
621
+ } catch {
622
+ // Fall back to an unauthenticated HTTP request when `gh` is unavailable.
623
+ }
624
+
625
+ const response = await fetchImpl(`https://api.github.com/repos/${repoSlug}/pulls?state=open&per_page=50`, {
626
+ headers: {
627
+ Accept: "application/vnd.github+json",
628
+ },
629
+ })
630
+
631
+ if (!response.ok) {
632
+ throw new Error(`GitHub pull requests request failed with status ${response.status}`)
633
+ }
634
+
635
+ const json = await response.json()
636
+ return Array.isArray(json) ? json as GitHubPullRequestResponseItem[] : []
637
+ }
638
+
639
+ function buildGitHubCommitUrl(remoteUrl: string | null, sha: string) {
640
+ const slug = extractGitHubRepoSlug(remoteUrl)
641
+ return slug ? `https://github.com/${slug}/commit/${sha}` : undefined
642
+ }
643
+
644
+ async function getTagsByCommit(repoRoot: string, shas: string[]): Promise<Map<string, string[]>> {
645
+ const tagMap = new Map<string, string[]>()
646
+ if (shas.length === 0) return tagMap
647
+
648
+ for (const sha of shas) {
649
+ tagMap.set(sha, [])
650
+ }
651
+
652
+ const result = await runGit(
653
+ ["log", "--max-count", String(shas.length), "--decorate-refs=refs/tags", "--format=%H %D", shas[0]!],
654
+ repoRoot
655
+ )
656
+
657
+ if (result.exitCode !== 0) return tagMap
658
+
659
+ for (const line of result.stdout.split(/\r?\n/u)) {
660
+ const trimmed = line.trim()
661
+ if (!trimmed) continue
662
+ const spaceIndex = trimmed.indexOf(" ")
663
+ if (spaceIndex < 0) continue
664
+ const sha = trimmed.slice(0, spaceIndex)
665
+ const decorations = trimmed.slice(spaceIndex + 1)
666
+ if (!tagMap.has(sha) || !decorations) continue
667
+ const tags = decorations
668
+ .split(",")
669
+ .map((decoration) => decoration.trim())
670
+ .filter((decoration) => decoration.startsWith("tag: "))
671
+ .map((decoration) => decoration.slice(5))
672
+ .filter(Boolean)
673
+ .sort((left, right) => left.localeCompare(right))
674
+ tagMap.set(sha, tags)
675
+ }
676
+
677
+ return tagMap
678
+ }
679
+
680
+ async function getBranchHistory(args: {
681
+ repoRoot: string
682
+ ref: string
683
+ limit: number
684
+ }): Promise<ChatBranchHistorySnapshot> {
685
+ const logResult = await runGit(
686
+ [
687
+ "log",
688
+ "--max-count",
689
+ String(args.limit),
690
+ "--pretty=format:%H%x1f%s%x1f%b%x1f%an%x1f%aI%x1e",
691
+ args.ref,
692
+ ],
693
+ args.repoRoot
694
+ )
695
+
696
+ if (logResult.exitCode !== 0) {
697
+ throw new Error(logResult.stderr.trim() || "Failed to read git log")
698
+ }
699
+
700
+ const remoteUrl = await getOriginRemoteUrl(args.repoRoot)
701
+ const parsedRecords: Array<{ sha: string; summary: string; description: string; authorName?: string; authoredAt: string }> = []
702
+
703
+ for (const record of logResult.stdout.split("\u001e")) {
704
+ const trimmed = record.trim()
705
+ if (!trimmed) continue
706
+ const [sha, summary, description, authorName, authoredAt] = trimmed.split("\u001f")
707
+ if (!sha || !summary || !authoredAt) continue
708
+ parsedRecords.push({
709
+ sha,
710
+ summary,
711
+ description: (description ?? "").trim(),
712
+ authorName: authorName?.trim() || undefined,
713
+ authoredAt,
714
+ })
715
+ }
716
+
717
+ const tagMap = await getTagsByCommit(args.repoRoot, parsedRecords.map((record) => record.sha))
718
+
719
+ const entries: ChatBranchHistoryEntry[] = parsedRecords.map((record) => ({
720
+ ...record,
721
+ tags: tagMap.get(record.sha) ?? [],
722
+ githubUrl: buildGitHubCommitUrl(remoteUrl, record.sha),
723
+ }))
724
+
725
+ return { entries }
726
+ }
727
+
728
+ function createBranchActionFailure(title: string, detail: string, fallback: string) {
729
+ return {
730
+ ok: false,
731
+ title,
732
+ message: summarizeGitFailure(detail, fallback),
733
+ detail,
734
+ } as const
735
+ }
736
+
737
+ function createMergeActionFailure(args: {
738
+ title: string
739
+ detail: string
740
+ fallback: string
741
+ snapshotChanged: boolean
742
+ }) {
743
+ return {
744
+ ok: false,
745
+ title: args.title,
746
+ message: summarizeGitFailure(args.detail, args.fallback),
747
+ detail: args.detail,
748
+ snapshotChanged: args.snapshotChanged,
749
+ } as const
750
+ }
751
+
752
+ function sanitizeRepoName(name: string) {
753
+ return name
754
+ .trim()
755
+ .toLowerCase()
756
+ .replace(/[\s_]+/gu, "-")
757
+ .replace(/[^a-z0-9.-]+/gu, "-")
758
+ .replace(/-+/gu, "-")
759
+ .replace(/^-|-$/gu, "")
760
+ }
761
+
762
+ async function getGhAuthInfo() {
763
+ const versionResult = await runCommand(["gh", "--version"])
764
+ if (versionResult.exitCode !== 0) {
765
+ return {
766
+ ghInstalled: false,
767
+ authenticated: false,
768
+ activeAccountLogin: undefined,
769
+ }
770
+ }
771
+
772
+ const authStatusResult = await runCommand(["gh", "auth", "status", "--json", "hosts"])
773
+ if (authStatusResult.exitCode !== 0) {
774
+ return {
775
+ ghInstalled: true,
776
+ authenticated: false,
777
+ activeAccountLogin: undefined,
778
+ }
779
+ }
780
+
781
+ try {
782
+ const parsed = JSON.parse(authStatusResult.stdout) as {
783
+ hosts?: Record<string, Array<{ active?: boolean; login?: string; state?: string }>>
784
+ }
785
+ const accounts = parsed.hosts?.["github.com"] ?? []
786
+ const activeAccount = accounts.find((account) => account.active) ?? accounts[0]
787
+ return {
788
+ ghInstalled: true,
789
+ authenticated: activeAccount?.state === "success",
790
+ activeAccountLogin: activeAccount?.login,
791
+ }
792
+ } catch {
793
+ return {
794
+ ghInstalled: true,
795
+ authenticated: false,
796
+ activeAccountLogin: undefined,
797
+ }
798
+ }
799
+ }
800
+
801
+ async function getGitHubOwners(): Promise<string[]> {
802
+ const userResult = await runCommand(["gh", "api", "user", "--jq", ".login"])
803
+ if (userResult.exitCode !== 0) {
804
+ return []
805
+ }
806
+
807
+ const owners = new Set<string>()
808
+ const userLogin = userResult.stdout.trim()
809
+ if (userLogin) {
810
+ owners.add(userLogin)
811
+ }
812
+
813
+ const orgsResult = await runCommand(["gh", "api", "user/orgs", "--paginate", "--jq", ".[].login"])
814
+ if (orgsResult.exitCode === 0) {
815
+ for (const line of orgsResult.stdout.split(/\r?\n/u)) {
816
+ const login = line.trim()
817
+ if (login) {
818
+ owners.add(login)
819
+ }
820
+ }
821
+ }
822
+
823
+ return [...owners]
824
+ }
825
+
826
+ function parseStatusPaths(output: string): DirtyPathEntry[] {
827
+ const entries: DirtyPathEntry[] = []
828
+ for (const rawLine of output.split(/\r?\n/u)) {
829
+ const line = rawLine.trimEnd()
830
+ if (line.length < 4) continue
831
+ const statusCode = line.slice(0, 2)
832
+ const value = line.slice(3)
833
+ if (!value) continue
834
+ const isUntracked = statusCode === "??"
835
+ const isRename = statusCode.includes("R")
836
+ const isDelete = statusCode.includes("D")
837
+ const isAdd = statusCode.includes("A") || isUntracked
838
+ const changeType: ChatDiffFile["changeType"] = isRename
839
+ ? "renamed"
840
+ : isDelete
841
+ ? "deleted"
842
+ : isAdd
843
+ ? "added"
844
+ : "modified"
845
+
846
+ if (isRename && value.includes(" -> ")) {
847
+ const [previousPath, nextPath] = value.split(" -> ")
848
+ if (nextPath) {
849
+ entries.push({
850
+ path: nextPath,
851
+ previousPath: previousPath || undefined,
852
+ changeType,
853
+ isUntracked,
854
+ })
855
+ }
856
+ continue
857
+ }
858
+
859
+ entries.push({
860
+ path: value,
861
+ changeType,
862
+ isUntracked,
863
+ })
864
+ }
865
+ return entries.sort((left, right) => left.path.localeCompare(right.path))
866
+ }
867
+
868
+ async function listDirtyPaths(repoRoot: string) {
869
+ const status = await runGit(["status", "--short", "--untracked-files=all"], repoRoot)
870
+ if (status.exitCode !== 0) {
871
+ throw new Error(status.stderr.trim() || "Failed to read git status")
872
+ }
873
+
874
+ const paths = parseStatusPaths(status.stdout)
875
+ return paths
876
+ }
877
+
878
+ async function readWorktreeFile(repoRoot: string, relativePath: string): Promise<string | null> {
879
+ const absolutePath = path.join(repoRoot, relativePath)
880
+ const fileInfo = await stat(absolutePath).catch(() => null)
881
+ if (!fileInfo?.isFile()) {
882
+ return null
883
+ }
884
+
885
+ return await readFile(absolutePath, "utf8")
886
+ }
887
+
888
+ async function readBaseFile(repoRoot: string, baseCommit: string | null, relativePath: string): Promise<string | null> {
889
+ if (!baseCommit) {
890
+ return null
891
+ }
892
+
893
+ const result = await runGit(["show", `${baseCommit}:${relativePath}`], repoRoot)
894
+ if (result.exitCode !== 0) {
895
+ return null
896
+ }
897
+ return result.stdout
898
+ }
899
+
900
+ async function createPatch(beforePathLabel: string, afterPathLabel: string, beforeText: string | null, afterText: string | null) {
901
+ const tempDir = await mkdtemp(path.join(tmpdir(), "kanna-diff-"))
902
+ const beforePath = path.join(tempDir, "before")
903
+ const afterPath = path.join(tempDir, "after")
904
+
905
+ try {
906
+ await writeFile(beforePath, beforeText ?? "", "utf8")
907
+ await writeFile(afterPath, afterText ?? "", "utf8")
908
+
909
+ const result = await runGit(
910
+ [
911
+ "diff",
912
+ "--no-index",
913
+ "--no-ext-diff",
914
+ "--text",
915
+ "--unified=3",
916
+ "--src-prefix=a/",
917
+ "--dst-prefix=b/",
918
+ "before",
919
+ "after",
920
+ ],
921
+ tempDir
922
+ )
923
+
924
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
925
+ throw new Error(result.stderr.trim() || `Failed to build patch for ${afterPathLabel}`)
926
+ }
927
+
928
+ return result.stdout
929
+ .replace("diff --git a/before b/after", `diff --git a/${beforePathLabel} b/${afterPathLabel}`)
930
+ .replace("--- a/before", `--- a/${beforePathLabel}`)
931
+ .replace("+++ b/after", `+++ b/${afterPathLabel}`)
932
+ } finally {
933
+ await rm(tempDir, { recursive: true, force: true })
934
+ }
935
+ }
936
+
937
+ function getContentDigest(args: {
938
+ changeType: ChatDiffFile["changeType"]
939
+ beforePath: string
940
+ afterPath: string
941
+ beforeText: string | null
942
+ afterText: string | null
943
+ }) {
944
+ return createHash("sha1")
945
+ .update(args.changeType)
946
+ .update("\u0000")
947
+ .update(args.beforePath)
948
+ .update("\u0000")
949
+ .update(args.afterPath)
950
+ .update("\u0000")
951
+ .update(args.beforeText ?? "")
952
+ .update("\u0000")
953
+ .update(args.afterText ?? "")
954
+ .digest("hex")
955
+ }
956
+
957
+ function parseNumstatValue(value: string) {
958
+ if (value === "-" || value.trim() === "") return 0
959
+ const parsed = Number.parseInt(value, 10)
960
+ return Number.isFinite(parsed) ? parsed : 0
961
+ }
962
+
963
+ function countTextLines(text: string | null) {
964
+ if (!text) return 0
965
+ const lines = text.split(/\r?\n/u)
966
+ if (lines.at(-1) === "") {
967
+ lines.pop()
968
+ }
969
+ return lines.length
970
+ }
971
+
972
+ async function getTrackedDiffStats(repoRoot: string, baseCommit: string | null) {
973
+ const statsByPath = new Map<string, { additions: number; deletions: number }>()
974
+ if (!baseCommit) {
975
+ return statsByPath
976
+ }
977
+
978
+ const result = await runGit(["diff", "--numstat", "-z", "-M", baseCommit], repoRoot)
979
+ if (result.exitCode !== 0) {
980
+ throw new Error(result.stderr.trim() || "Failed to read git diff stats")
981
+ }
982
+
983
+ const tokens = result.stdout.split("\u0000")
984
+ for (let index = 0; index < tokens.length;) {
985
+ const header = tokens[index++] ?? ""
986
+ if (!header) continue
987
+
988
+ const [additionsValue, deletionsValue, pathValue = ""] = header.split("\t")
989
+ if (typeof additionsValue !== "string" || typeof deletionsValue !== "string") continue
990
+
991
+ if (pathValue) {
992
+ statsByPath.set(pathValue, {
993
+ additions: parseNumstatValue(additionsValue),
994
+ deletions: parseNumstatValue(deletionsValue),
995
+ })
996
+ continue
997
+ }
998
+
999
+ index += 1
1000
+ const nextPath = tokens[index++] ?? ""
1001
+ if (!nextPath) continue
1002
+ statsByPath.set(nextPath, {
1003
+ additions: parseNumstatValue(additionsValue),
1004
+ deletions: parseNumstatValue(deletionsValue),
1005
+ })
1006
+ }
1007
+
1008
+ return statsByPath
1009
+ }
1010
+
1011
+ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null): Promise<ChatDiffFile[]> {
1012
+ const currentDirtyPaths = await listDirtyPaths(repoRoot)
1013
+ const trackedStatsByPath = await getTrackedDiffStats(repoRoot, baseCommit)
1014
+ const files: ChatDiffFile[] = []
1015
+
1016
+ for (const entry of currentDirtyPaths) {
1017
+ const relativePath = entry.path
1018
+ const beforePath = entry.previousPath ?? relativePath
1019
+ const beforeText = await readBaseFile(repoRoot, baseCommit, beforePath)
1020
+ const afterText = await readWorktreeFile(repoRoot, relativePath)
1021
+ const absolutePath = path.join(repoRoot, relativePath)
1022
+ const fileInfo = await stat(absolutePath).catch(() => null)
1023
+ const file = fileInfo?.isFile() ? Bun.file(absolutePath) : null
1024
+ const mimeType = file ? inferProjectFileContentType(relativePath, file.type) : undefined
1025
+ const size = fileInfo?.isFile() ? fileInfo.size : undefined
1026
+
1027
+ if (beforeText === afterText && entry.changeType !== "renamed") {
1028
+ continue
1029
+ }
1030
+
1031
+ const trackedStats = trackedStatsByPath.get(relativePath)
1032
+ const additions = trackedStats?.additions ?? countTextLines(afterText)
1033
+ const deletions = trackedStats?.deletions ?? 0
1034
+ files.push({
1035
+ path: relativePath,
1036
+ changeType: entry.changeType,
1037
+ isUntracked: entry.isUntracked,
1038
+ additions,
1039
+ deletions,
1040
+ patchDigest: getContentDigest({
1041
+ changeType: entry.changeType,
1042
+ beforePath,
1043
+ afterPath: relativePath,
1044
+ beforeText,
1045
+ afterText,
1046
+ }),
1047
+ mimeType,
1048
+ size,
1049
+ })
1050
+ }
1051
+
1052
+ return files
1053
+ }
1054
+
1055
+ function normalizeRepoRelativePath(inputPath: string) {
1056
+ const normalized = path.posix.normalize(inputPath.replaceAll("\\", "/")).replace(/^\.\/+/u, "")
1057
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../") || path.posix.isAbsolute(normalized)) {
1058
+ throw new Error(`Invalid diff path: ${inputPath}`)
1059
+ }
1060
+ return normalized
1061
+ }
1062
+
1063
+ async function findDirtyPath(repoRoot: string, relativePath: string) {
1064
+ const dirtyPaths = await listDirtyPaths(repoRoot)
1065
+ return dirtyPaths.find((entry) => entry.path === relativePath)
1066
+ }
1067
+
1068
+ async function discardAddedPath(repoRoot: string, repoHasHead: boolean, relativePath: string) {
1069
+ if (repoHasHead) {
1070
+ const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", relativePath], repoRoot)
1071
+ if (resetResult.exitCode !== 0) {
1072
+ throw new Error(formatGitFailure(resetResult) || "Failed to unstage added file")
1073
+ }
1074
+ } else {
1075
+ const rmCachedResult = await runGit(["rm", "--cached", "--force", "--", relativePath], repoRoot)
1076
+ if (rmCachedResult.exitCode !== 0) {
1077
+ throw new Error(formatGitFailure(rmCachedResult) || "Failed to unstage added file")
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ async function discardRenamedPath(repoRoot: string, entry: DirtyPathEntry) {
1083
+ if (!entry.previousPath) {
1084
+ throw new Error(`Missing previous path for renamed file: ${entry.path}`)
1085
+ }
1086
+
1087
+ const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", entry.path], repoRoot)
1088
+ if (resetResult.exitCode !== 0) {
1089
+ throw new Error(formatGitFailure(resetResult) || "Failed to unstage renamed file")
1090
+ }
1091
+
1092
+ const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.previousPath], repoRoot)
1093
+ if (restoreResult.exitCode !== 0) {
1094
+ throw new Error(formatGitFailure(restoreResult) || "Failed to restore renamed file")
1095
+ }
1096
+
1097
+ await rm(path.join(repoRoot, entry.path), { recursive: true, force: true })
1098
+ }
1099
+
1100
+ export function appendGitIgnoreEntry(currentContents: string | null, entry: string) {
1101
+ const normalizedContents = currentContents ?? ""
1102
+ const existingEntries = normalizedContents
1103
+ .split(/\r?\n/u)
1104
+ .map((line) => line.trim())
1105
+ .filter(Boolean)
1106
+
1107
+ if (existingEntries.includes(entry)) {
1108
+ return normalizedContents.length > 0 && !normalizedContents.endsWith("\n")
1109
+ ? `${normalizedContents}\n`
1110
+ : normalizedContents
1111
+ }
1112
+
1113
+ const prefix = normalizedContents.length === 0
1114
+ ? ""
1115
+ : normalizedContents.endsWith("\n")
1116
+ ? normalizedContents
1117
+ : `${normalizedContents}\n`
1118
+ return `${prefix}${entry}\n`
1119
+ }
1120
+
1121
+ export class DiffStore {
1122
+ private readonly states = new Map<string, StoredChatDiffState>()
1123
+
1124
+ constructor(_: string) {}
1125
+
1126
+ async initialize() {}
1127
+
1128
+ async initializeGit(args: {
1129
+ projectId: string
1130
+ projectPath: string
1131
+ }): Promise<BranchActionSuccess | BranchActionFailure> {
1132
+ const existingRepo = await resolveRepo(args.projectPath)
1133
+ if (existingRepo) {
1134
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1135
+ return {
1136
+ ok: true,
1137
+ branchName: await getBranchName(existingRepo.repoRoot),
1138
+ snapshotChanged,
1139
+ }
1140
+ }
1141
+
1142
+ const initResult = await runGit(["init"], args.projectPath)
1143
+ if (initResult.exitCode !== 0) {
1144
+ return createBranchActionFailure("Initialize git failed", formatGitFailure(initResult), "Git could not initialize this folder.")
1145
+ }
1146
+
1147
+ const repo = await resolveRepo(args.projectPath)
1148
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1149
+ return {
1150
+ ok: true,
1151
+ branchName: repo ? await getBranchName(repo.repoRoot) : undefined,
1152
+ snapshotChanged,
1153
+ }
1154
+ }
1155
+
1156
+ async getGitHubPublishInfo(args: {
1157
+ projectPath: string
1158
+ }): Promise<GitHubPublishInfo> {
1159
+ const authInfo = await getGhAuthInfo()
1160
+ const suggestedRepoName = sanitizeRepoName(path.basename(args.projectPath)) || "my-repo"
1161
+
1162
+ if (!authInfo.ghInstalled || !authInfo.authenticated) {
1163
+ return {
1164
+ ghInstalled: authInfo.ghInstalled,
1165
+ authenticated: authInfo.authenticated,
1166
+ activeAccountLogin: authInfo.activeAccountLogin,
1167
+ owners: authInfo.activeAccountLogin ? [authInfo.activeAccountLogin] : [],
1168
+ suggestedRepoName,
1169
+ }
1170
+ }
1171
+
1172
+ const owners = await getGitHubOwners()
1173
+ return {
1174
+ ghInstalled: true,
1175
+ authenticated: true,
1176
+ activeAccountLogin: authInfo.activeAccountLogin,
1177
+ owners,
1178
+ suggestedRepoName,
1179
+ }
1180
+ }
1181
+
1182
+ async checkGitHubRepoAvailability(args: {
1183
+ owner: string
1184
+ name: string
1185
+ }): Promise<GitHubRepoAvailabilityResult> {
1186
+ const authInfo = await getGhAuthInfo()
1187
+ if (!authInfo.ghInstalled) {
1188
+ return {
1189
+ available: false,
1190
+ message: "GitHub CLI is not installed.",
1191
+ }
1192
+ }
1193
+ if (!authInfo.authenticated) {
1194
+ return {
1195
+ available: false,
1196
+ message: "GitHub CLI is not authenticated.",
1197
+ }
1198
+ }
1199
+
1200
+ const owner = args.owner.trim()
1201
+ const name = sanitizeRepoName(args.name)
1202
+ if (!owner || !name) {
1203
+ return {
1204
+ available: false,
1205
+ message: "Enter an owner and repository name.",
1206
+ }
1207
+ }
1208
+
1209
+ const result = await runCommand(["gh", "api", `repos/${owner}/${name}`])
1210
+ if (result.exitCode === 0) {
1211
+ return {
1212
+ available: false,
1213
+ message: `${owner}/${name} already exists.`,
1214
+ }
1215
+ }
1216
+
1217
+ const detail = `${result.stderr}\n${result.stdout}`.toLowerCase()
1218
+ if (detail.includes("404")) {
1219
+ return {
1220
+ available: true,
1221
+ message: `${owner}/${name} is available.`,
1222
+ }
1223
+ }
1224
+
1225
+ return {
1226
+ available: false,
1227
+ message: "Could not verify repository availability.",
1228
+ }
1229
+ }
1230
+
1231
+ async publishToGitHub(args: {
1232
+ projectId: string
1233
+ projectPath: string
1234
+ owner: string
1235
+ name: string
1236
+ visibility: "public" | "private"
1237
+ description?: string
1238
+ }): Promise<BranchActionSuccess | BranchActionFailure> {
1239
+ const repo = await resolveRepo(args.projectPath)
1240
+ if (!repo) {
1241
+ return {
1242
+ ok: false,
1243
+ title: "Publish failed",
1244
+ message: "Initialize git before publishing to GitHub.",
1245
+ snapshotChanged: false,
1246
+ }
1247
+ }
1248
+
1249
+ const authInfo = await getGhAuthInfo()
1250
+ if (!authInfo.ghInstalled) {
1251
+ return {
1252
+ ok: false,
1253
+ title: "GitHub CLI not installed",
1254
+ message: "Install GitHub CLI (`gh`) to publish from Kanna.",
1255
+ snapshotChanged: false,
1256
+ }
1257
+ }
1258
+ if (!authInfo.authenticated) {
1259
+ return {
1260
+ ok: false,
1261
+ title: "GitHub CLI not signed in",
1262
+ message: "Run `gh auth login` and try again.",
1263
+ snapshotChanged: false,
1264
+ }
1265
+ }
1266
+
1267
+ const owner = args.owner.trim()
1268
+ const repoName = sanitizeRepoName(args.name)
1269
+ if (!owner || !repoName) {
1270
+ return {
1271
+ ok: false,
1272
+ title: "Publish failed",
1273
+ message: "Owner and repository name are required.",
1274
+ snapshotChanged: false,
1275
+ }
1276
+ }
1277
+
1278
+ const availability = await this.checkGitHubRepoAvailability({ owner, name: repoName })
1279
+ if (!availability.available) {
1280
+ return {
1281
+ ok: false,
1282
+ title: "Publish failed",
1283
+ message: availability.message,
1284
+ snapshotChanged: false,
1285
+ }
1286
+ }
1287
+
1288
+ const createArgs = [
1289
+ "gh",
1290
+ "repo",
1291
+ "create",
1292
+ `${owner}/${repoName}`,
1293
+ args.visibility === "private" ? "--private" : "--public",
1294
+ "--source",
1295
+ args.projectPath,
1296
+ "--remote",
1297
+ "origin",
1298
+ ]
1299
+ if (repo.baseCommit) {
1300
+ createArgs.push("--push")
1301
+ }
1302
+ if (args.description?.trim()) {
1303
+ createArgs.push("--description", args.description.trim())
1304
+ }
1305
+
1306
+ const createResult = await runCommand(createArgs)
1307
+ if (createResult.exitCode !== 0) {
1308
+ const detail = [createResult.stderr.trim(), createResult.stdout.trim()].filter(Boolean).join("\n")
1309
+ return {
1310
+ ok: false,
1311
+ title: "Publish failed",
1312
+ message: summarizeGitFailure(detail, "GitHub CLI could not publish this repository."),
1313
+ detail,
1314
+ snapshotChanged: false,
1315
+ }
1316
+ }
1317
+
1318
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1319
+ return {
1320
+ ok: true,
1321
+ branchName: await getBranchName(repo.repoRoot),
1322
+ snapshotChanged,
1323
+ }
1324
+ }
1325
+
1326
+ async readPatch(args: {
1327
+ projectPath: string
1328
+ path: string
1329
+ }) {
1330
+ const relativePath = normalizeRepoRelativePath(args.path)
1331
+ const repo = await resolveRepo(args.projectPath)
1332
+ if (!repo) {
1333
+ throw new Error("Project is not in a git repository")
1334
+ }
1335
+
1336
+ const entry = await findDirtyPath(repo.repoRoot, relativePath)
1337
+ if (!entry) {
1338
+ throw new Error(`File is no longer changed: ${relativePath}`)
1339
+ }
1340
+
1341
+ const beforePath = entry.previousPath ?? relativePath
1342
+ const beforeText = await readBaseFile(repo.repoRoot, repo.baseCommit, beforePath)
1343
+ const afterText = await readWorktreeFile(repo.repoRoot, relativePath)
1344
+ const patch = await createPatch(beforePath, relativePath, beforeText, afterText)
1345
+
1346
+ return { patch }
1347
+ }
1348
+
1349
+ getProjectSnapshot(projectId: string): ChatDiffSnapshot {
1350
+ const state = this.states.get(projectId) ?? createEmptyState()
1351
+ return {
1352
+ status: state.status,
1353
+ branchName: state.branchName,
1354
+ defaultBranchName: state.defaultBranchName,
1355
+ hasOriginRemote: state.hasOriginRemote,
1356
+ originRepoSlug: state.originRepoSlug,
1357
+ hasUpstream: state.hasUpstream,
1358
+ aheadCount: state.aheadCount,
1359
+ behindCount: state.behindCount,
1360
+ lastFetchedAt: state.lastFetchedAt,
1361
+ files: [...state.files],
1362
+ branchHistory: {
1363
+ entries: state.branchHistory.entries.map((entry) => ({
1364
+ ...entry,
1365
+ tags: [...entry.tags],
1366
+ })),
1367
+ },
1368
+ }
1369
+ }
1370
+
1371
+ async refreshSnapshot(projectId: string, projectPath: string) {
1372
+ const repo = await resolveRepo(projectPath)
1373
+ if (!repo) {
1374
+ const nextState = {
1375
+ status: "no_repo",
1376
+ branchName: undefined,
1377
+ defaultBranchName: undefined,
1378
+ hasOriginRemote: undefined,
1379
+ originRepoSlug: undefined,
1380
+ hasUpstream: undefined,
1381
+ aheadCount: undefined,
1382
+ behindCount: undefined,
1383
+ lastFetchedAt: undefined,
1384
+ files: [],
1385
+ branchHistory: { entries: [] },
1386
+ } satisfies StoredChatDiffState
1387
+ const changed = !snapshotsEqual(this.states.get(projectId), nextState)
1388
+ this.states.set(projectId, nextState)
1389
+ return changed
1390
+ }
1391
+
1392
+ const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
1393
+ const branchName = await getBranchName(repo.repoRoot)
1394
+ const defaultBranchName = await resolveDefaultBranchName(repo.repoRoot)
1395
+ const originRemoteUrl = await getOriginRemoteUrl(repo.repoRoot)
1396
+ const hasOriginRemote = originRemoteUrl !== null
1397
+ const originRepoSlug = extractGitHubRepoSlug(originRemoteUrl) ?? undefined
1398
+ const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
1399
+ const { aheadCount, behindCount } = hasUpstream
1400
+ ? await getUpstreamStatusCounts(repo.repoRoot)
1401
+ : { aheadCount: undefined, behindCount: undefined }
1402
+ const lastFetchedAt = await getLastFetchedAt(repo.repoRoot)
1403
+ const branchHistory = repo.baseCommit
1404
+ ? await getBranchHistory({
1405
+ repoRoot: repo.repoRoot,
1406
+ ref: branchName ?? "HEAD",
1407
+ limit: 20,
1408
+ })
1409
+ : { entries: [] }
1410
+ const nextState = {
1411
+ status: "ready",
1412
+ branchName,
1413
+ defaultBranchName,
1414
+ hasOriginRemote,
1415
+ originRepoSlug,
1416
+ hasUpstream,
1417
+ aheadCount,
1418
+ behindCount,
1419
+ lastFetchedAt,
1420
+ files,
1421
+ branchHistory,
1422
+ } satisfies StoredChatDiffState
1423
+ const changed = !snapshotsEqual(this.states.get(projectId), nextState)
1424
+ this.states.set(projectId, nextState)
1425
+ return changed
1426
+ }
1427
+
1428
+ async listBranches(args: {
1429
+ projectPath: string
1430
+ }): Promise<ChatBranchListResult> {
1431
+ const repo = await resolveRepo(args.projectPath)
1432
+ if (!repo) {
1433
+ throw new Error("Project is not in a git repository")
1434
+ }
1435
+
1436
+ const [currentBranchName, defaultBranchName, localBranchNames, remoteBranchNames, recentBranchNames, localUpdatedAtMap, remoteUpdatedAtMap] = await Promise.all([
1437
+ getBranchName(repo.repoRoot),
1438
+ resolveDefaultBranchName(repo.repoRoot),
1439
+ getLocalBranchNames(repo.repoRoot),
1440
+ getRemoteBranchNames(repo.repoRoot),
1441
+ getRecentBranchNames(repo.repoRoot),
1442
+ getBranchUpdatedAtMap(repo.repoRoot, "refs/heads"),
1443
+ getBranchUpdatedAtMap(repo.repoRoot, "refs/remotes"),
1444
+ ])
1445
+
1446
+ const local = localBranchNames.map((name) => ({
1447
+ id: `local:${name}`,
1448
+ kind: "local",
1449
+ name,
1450
+ displayName: name,
1451
+ updatedAt: localUpdatedAtMap.get(name),
1452
+ } satisfies ChatBranchListEntry))
1453
+
1454
+ const remote = remoteBranchNames.map((remoteRef) => ({
1455
+ id: `remote:${remoteRef}`,
1456
+ kind: "remote",
1457
+ name: remoteRef.replace(/^[^/]+\//u, ""),
1458
+ displayName: remoteRef,
1459
+ updatedAt: remoteUpdatedAtMap.get(remoteRef),
1460
+ remoteRef,
1461
+ } satisfies ChatBranchListEntry))
1462
+
1463
+ const localBranchSet = new Set(localBranchNames)
1464
+ const remoteByName = new Map(remote.map((entry) => [entry.name, entry]))
1465
+ const remoteEntriesByName = new Map<string, ChatBranchListEntry[]>()
1466
+ for (const entry of remote) {
1467
+ const entries = remoteEntriesByName.get(entry.name) ?? []
1468
+ entries.push(entry)
1469
+ remoteEntriesByName.set(entry.name, entries)
1470
+ }
1471
+ const recent: ChatBranchListEntry[] = recentBranchNames.flatMap<ChatBranchListEntry>((branchName) => {
1472
+ if (localBranchSet.has(branchName)) {
1473
+ return {
1474
+ id: `recent:local:${branchName}`,
1475
+ kind: "local",
1476
+ name: branchName,
1477
+ displayName: branchName,
1478
+ updatedAt: localUpdatedAtMap.get(branchName),
1479
+ } satisfies ChatBranchListEntry
1480
+ }
1481
+ const remoteEntry = remoteByName.get(branchName)
1482
+ return remoteEntry
1483
+ ? {
1484
+ ...remoteEntry,
1485
+ id: `recent:${remoteEntry.id}`,
1486
+ } satisfies ChatBranchListEntry
1487
+ : []
1488
+ })
1489
+
1490
+ const [remoteUrl, githubRemoteSlugs] = await Promise.all([
1491
+ getOriginRemoteUrl(repo.repoRoot),
1492
+ getGitHubRemoteSlugs(repo.repoRoot),
1493
+ ])
1494
+ const repoSlug = extractGitHubRepoSlug(remoteUrl)
1495
+ let pullRequests: ChatBranchListEntry[] = []
1496
+ const pullRequestRemoteRefs = new Set<string>()
1497
+ const pullRequestHeadNames = new Set<string>()
1498
+ let pullRequestsStatus: ChatBranchListResult["pullRequestsStatus"] = "unavailable"
1499
+ let pullRequestsError: string | undefined
1500
+
1501
+ if (repoSlug) {
1502
+ try {
1503
+ const prs = await fetchGitHubPullRequests(repoSlug)
1504
+ pullRequests = prs.flatMap<ChatBranchListEntry>((pr) => {
1505
+ const headRefName = pr.head?.ref?.trim()
1506
+ if (!headRefName) return []
1507
+ pullRequestHeadNames.add(headRefName)
1508
+ const cloneUrl = pr.head?.repo?.clone_url?.trim() || undefined
1509
+ const fullName = pr.head?.repo?.full_name?.trim() || undefined
1510
+ const headRepoSlug = fullName?.toLowerCase()
1511
+ const matchingRemoteEntries = (remoteEntriesByName.get(headRefName) ?? []).filter((entry) => {
1512
+ const remoteName = entry.remoteRef?.split("/")[0]
1513
+ if (!remoteName) return false
1514
+ const remoteSlug = githubRemoteSlugs.get(remoteName)
1515
+ if (!remoteSlug) return false
1516
+ if (headRepoSlug) {
1517
+ return remoteSlug === headRepoSlug
1518
+ }
1519
+ return remoteName === "origin"
1520
+ })
1521
+ for (const entry of matchingRemoteEntries) {
1522
+ if (entry.remoteRef) {
1523
+ pullRequestRemoteRefs.add(entry.remoteRef)
1524
+ }
1525
+ }
1526
+ const preferredRemoteEntry = matchingRemoteEntries[0] ?? remoteByName.get(headRefName)
1527
+ const remoteRef = preferredRemoteEntry?.remoteRef ?? `origin/${headRefName}`
1528
+ return {
1529
+ id: `pr:${pr.number}`,
1530
+ kind: "pull_request",
1531
+ name: headRefName,
1532
+ displayName: `PR #${pr.number}`,
1533
+ updatedAt: (remoteRef ? remoteUpdatedAtMap.get(remoteRef) : undefined) ?? localUpdatedAtMap.get(headRefName),
1534
+ description: pr.title,
1535
+ remoteRef,
1536
+ prNumber: pr.number,
1537
+ prTitle: pr.title,
1538
+ headRefName,
1539
+ headLabel: pr.head?.label?.trim() || fullName,
1540
+ headRepoCloneUrl: cloneUrl,
1541
+ isCrossRepository: Boolean(fullName && fullName.toLowerCase() !== repoSlug.toLowerCase()),
1542
+ } satisfies ChatBranchListEntry
1543
+ })
1544
+ pullRequestsStatus = "available"
1545
+ } catch (error) {
1546
+ pullRequestsStatus = "error"
1547
+ pullRequestsError = error instanceof Error ? error.message : String(error)
1548
+ }
1549
+ }
1550
+
1551
+ const visibleRemote = remote.filter((entry) => {
1552
+ if (pullRequestHeadNames.has(entry.name)) {
1553
+ return false
1554
+ }
1555
+ return !entry.remoteRef || !pullRequestRemoteRefs.has(entry.remoteRef)
1556
+ })
1557
+ const visibleRemoteByName = new Map(visibleRemote.map((entry) => [entry.name, entry]))
1558
+ const visibleRecent = recent.filter((entry) => entry.kind !== "remote" || !entry.remoteRef || visibleRemoteByName.has(entry.name))
1559
+
1560
+ return {
1561
+ currentBranchName,
1562
+ defaultBranchName,
1563
+ recent: visibleRecent,
1564
+ local,
1565
+ remote: visibleRemote,
1566
+ pullRequests,
1567
+ pullRequestsStatus,
1568
+ pullRequestsError,
1569
+ }
1570
+ }
1571
+
1572
+ async previewMergeBranch(args: {
1573
+ projectPath: string
1574
+ branch: SelectedBranch
1575
+ }): Promise<ChatMergePreviewResult> {
1576
+ const repo = await resolveRepo(args.projectPath)
1577
+ if (!repo) {
1578
+ throw new Error("Project is not in a git repository")
1579
+ }
1580
+
1581
+ const currentBranchName = await getBranchName(repo.repoRoot)
1582
+ const resolvedBranch = await resolveSelectedBranchRef(repo.repoRoot, args.branch)
1583
+
1584
+ if (currentBranchName && resolvedBranch.branchName === currentBranchName) {
1585
+ return {
1586
+ currentBranchName,
1587
+ targetBranchName: resolvedBranch.branchName,
1588
+ targetDisplayName: resolvedBranch.displayName,
1589
+ status: "up_to_date",
1590
+ commitCount: 0,
1591
+ hasConflicts: false,
1592
+ message: `${currentBranchName} is already up to date with ${resolvedBranch.displayName}.`,
1593
+ }
1594
+ }
1595
+
1596
+ try {
1597
+ const commitCount = await getMergeCommitCount(repo.repoRoot, resolvedBranch.ref)
1598
+ if (commitCount === 0) {
1599
+ return {
1600
+ currentBranchName,
1601
+ targetBranchName: resolvedBranch.branchName,
1602
+ targetDisplayName: resolvedBranch.displayName,
1603
+ status: "up_to_date",
1604
+ commitCount,
1605
+ hasConflicts: false,
1606
+ message: `${currentBranchName ?? "Current branch"} is already up to date with ${resolvedBranch.displayName}.`,
1607
+ }
1608
+ }
1609
+
1610
+ const conflictPrediction = await predictMergeConflicts(repo.repoRoot, resolvedBranch.ref)
1611
+ if (conflictPrediction.hasConflicts) {
1612
+ return {
1613
+ currentBranchName,
1614
+ targetBranchName: resolvedBranch.branchName,
1615
+ targetDisplayName: resolvedBranch.displayName,
1616
+ status: "conflicts",
1617
+ commitCount,
1618
+ hasConflicts: true,
1619
+ message: `${commitCount} ${commitCount === 1 ? "commit" : "commits"} from ${resolvedBranch.displayName} would merge into ${currentBranchName ?? "the current branch"}, but conflicts are expected.`,
1620
+ detail: conflictPrediction.detail,
1621
+ }
1622
+ }
1623
+
1624
+ return {
1625
+ currentBranchName,
1626
+ targetBranchName: resolvedBranch.branchName,
1627
+ targetDisplayName: resolvedBranch.displayName,
1628
+ status: "mergeable",
1629
+ commitCount,
1630
+ hasConflicts: false,
1631
+ message: `${commitCount} ${commitCount === 1 ? "commit" : "commits"} from ${resolvedBranch.displayName} will merge into ${currentBranchName ?? "the current branch"}.`,
1632
+ }
1633
+ } catch (error) {
1634
+ const message = error instanceof Error ? error.message : String(error)
1635
+ return {
1636
+ currentBranchName,
1637
+ targetBranchName: resolvedBranch.branchName,
1638
+ targetDisplayName: resolvedBranch.displayName,
1639
+ status: "error",
1640
+ commitCount: 0,
1641
+ hasConflicts: false,
1642
+ message: "Could not preview this merge.",
1643
+ detail: message,
1644
+ }
1645
+ }
1646
+ }
1647
+
1648
+ async mergeBranch(args: {
1649
+ projectId: string
1650
+ projectPath: string
1651
+ branch: SelectedBranch
1652
+ }): Promise<ChatMergeBranchResult> {
1653
+ const repo = await resolveRepo(args.projectPath)
1654
+ if (!repo) {
1655
+ throw new Error("Project is not in a git repository")
1656
+ }
1657
+
1658
+ const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
1659
+ if (currentDirtyPaths.length > 0) {
1660
+ return {
1661
+ ok: false,
1662
+ title: "Merge blocked",
1663
+ message: "Commit, discard, or stash your local changes before merging.",
1664
+ snapshotChanged: false,
1665
+ }
1666
+ }
1667
+
1668
+ const resolvedBranch = await resolveSelectedBranchRef(repo.repoRoot, args.branch)
1669
+ const commitCount = await getMergeCommitCount(repo.repoRoot, resolvedBranch.ref)
1670
+ if (commitCount === 0) {
1671
+ return {
1672
+ ok: false,
1673
+ title: "Already up to date",
1674
+ message: `${resolvedBranch.displayName} is already merged into ${await getBranchName(repo.repoRoot) ?? "the current branch"}.`,
1675
+ snapshotChanged: false,
1676
+ }
1677
+ }
1678
+
1679
+ const mergeResult = await runGit(["merge", "--no-edit", resolvedBranch.ref], repo.repoRoot)
1680
+ const detail = formatGitFailure(mergeResult)
1681
+
1682
+ if (mergeResult.exitCode !== 0) {
1683
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1684
+ const normalized = detail.toLowerCase()
1685
+ const title = normalized.includes("conflict")
1686
+ ? "Merge conflicts need resolution"
1687
+ : "Merge failed"
1688
+ const fallback = normalized.includes("conflict")
1689
+ ? "Git reported merge conflicts while merging this branch."
1690
+ : "Git could not merge this branch."
1691
+ return createMergeActionFailure({
1692
+ title,
1693
+ detail,
1694
+ fallback,
1695
+ snapshotChanged,
1696
+ })
1697
+ }
1698
+
1699
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1700
+ return {
1701
+ ok: true,
1702
+ branchName: await getBranchName(repo.repoRoot),
1703
+ snapshotChanged,
1704
+ }
1705
+ }
1706
+
1707
+ async checkoutBranch(args: {
1708
+ projectId: string
1709
+ projectPath: string
1710
+ branch: SelectedBranch
1711
+ bringChanges?: boolean
1712
+ }): Promise<ChatCheckoutBranchResult> {
1713
+ const repo = await resolveRepo(args.projectPath)
1714
+ if (!repo) {
1715
+ throw new Error("Project is not in a git repository")
1716
+ }
1717
+
1718
+ const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
1719
+ if (currentDirtyPaths.length > 0 && !args.bringChanges) {
1720
+ return {
1721
+ ok: false,
1722
+ cancelled: true,
1723
+ title: "Branch switch cancelled",
1724
+ message: "Your current changes were kept on the current branch.",
1725
+ snapshotChanged: false,
1726
+ }
1727
+ }
1728
+
1729
+ let switchResult: Awaited<ReturnType<typeof runGit>>
1730
+ if (args.branch.kind === "local") {
1731
+ switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
1732
+ } else if (args.branch.kind === "remote") {
1733
+ const localBranchNames = await getLocalBranchNames(repo.repoRoot)
1734
+ if (localBranchNames.includes(args.branch.name)) {
1735
+ switchResult = await runGit(["switch", args.branch.name], repo.repoRoot)
1736
+ } else {
1737
+ switchResult = await runGit(["switch", "--track", "--no-guess", args.branch.remoteRef], repo.repoRoot)
1738
+ }
1739
+ } else {
1740
+ const localBranchNames = await getLocalBranchNames(repo.repoRoot)
1741
+ let localBranchName = args.branch.name
1742
+
1743
+ if (localBranchNames.includes(localBranchName) && args.branch.isCrossRepository) {
1744
+ localBranchName = `${args.branch.name}-pr-${args.branch.prNumber}`
1745
+ }
1746
+
1747
+ if (localBranchNames.includes(localBranchName)) {
1748
+ switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
1749
+ } else if (args.branch.isCrossRepository && args.branch.headRepoCloneUrl) {
1750
+ const fetchResult = await runGit(
1751
+ [
1752
+ "fetch",
1753
+ "--no-tags",
1754
+ args.branch.headRepoCloneUrl,
1755
+ `refs/heads/${args.branch.headRefName}:refs/heads/${localBranchName}`,
1756
+ ],
1757
+ repo.repoRoot
1758
+ )
1759
+ if (fetchResult.exitCode !== 0) {
1760
+ return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
1761
+ }
1762
+ switchResult = await runGit(["switch", localBranchName], repo.repoRoot)
1763
+ } else {
1764
+ const remoteRef = args.branch.remoteRef ?? `origin/${args.branch.headRefName}`
1765
+ const remoteBranchNames = await getRemoteBranchNames(repo.repoRoot)
1766
+ if (!remoteBranchNames.includes(remoteRef)) {
1767
+ const fetchResult = await runGit(
1768
+ ["fetch", "--no-tags", "origin", `refs/heads/${args.branch.headRefName}:refs/remotes/${remoteRef}`],
1769
+ repo.repoRoot
1770
+ )
1771
+ if (fetchResult.exitCode !== 0) {
1772
+ return createBranchActionFailure("Checkout failed", formatGitFailure(fetchResult), "Git could not fetch the pull request branch.")
1773
+ }
1774
+ }
1775
+ switchResult = await runGit(["switch", "--track", "--no-guess", remoteRef], repo.repoRoot)
1776
+ }
1777
+ }
1778
+
1779
+ if (switchResult.exitCode !== 0) {
1780
+ return createBranchActionFailure("Checkout failed", formatGitFailure(switchResult), "Git could not switch branches.")
1781
+ }
1782
+
1783
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1784
+ return {
1785
+ ok: true,
1786
+ branchName: await getBranchName(repo.repoRoot),
1787
+ snapshotChanged,
1788
+ }
1789
+ }
1790
+
1791
+ async createBranch(args: {
1792
+ projectId: string
1793
+ projectPath: string
1794
+ name: string
1795
+ baseBranchName?: string
1796
+ }): Promise<ChatCreateBranchResult> {
1797
+ const repo = await resolveRepo(args.projectPath)
1798
+ if (!repo) {
1799
+ throw new Error("Project is not in a git repository")
1800
+ }
1801
+
1802
+ const branchName = args.name.trim()
1803
+ if (!branchName) {
1804
+ throw new Error("Branch name is required")
1805
+ }
1806
+
1807
+ const refValidation = await runGit(["check-ref-format", "--branch", branchName], repo.repoRoot)
1808
+ if (refValidation.exitCode !== 0) {
1809
+ return createBranchActionFailure("Create branch failed", formatGitFailure(refValidation), "Branch name is not valid.")
1810
+ }
1811
+
1812
+ const localBranchNames = await getLocalBranchNames(repo.repoRoot)
1813
+ if (localBranchNames.includes(branchName)) {
1814
+ return {
1815
+ ok: false,
1816
+ title: "Create branch failed",
1817
+ message: `A local branch named "${branchName}" already exists.`,
1818
+ snapshotChanged: false,
1819
+ }
1820
+ }
1821
+
1822
+ const baseBranchName = args.baseBranchName?.trim() || await resolveDefaultBranchName(repo.repoRoot) || await getBranchName(repo.repoRoot)
1823
+ if (!baseBranchName) {
1824
+ throw new Error("Could not determine a base branch")
1825
+ }
1826
+
1827
+ const switchResult = await runGit(["switch", "-c", branchName, baseBranchName], repo.repoRoot)
1828
+ if (switchResult.exitCode !== 0) {
1829
+ return createBranchActionFailure("Create branch failed", formatGitFailure(switchResult), "Git could not create the branch.")
1830
+ }
1831
+
1832
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1833
+ return {
1834
+ ok: true,
1835
+ branchName,
1836
+ snapshotChanged,
1837
+ }
1838
+ }
1839
+
1840
+ async syncBranch(args: {
1841
+ projectId: string
1842
+ projectPath: string
1843
+ action: "fetch" | "pull" | "push" | "publish"
1844
+ }): Promise<ChatSyncResult> {
1845
+ const repo = await resolveRepo(args.projectPath)
1846
+ if (!repo) {
1847
+ throw new Error("Project is not in a git repository")
1848
+ }
1849
+
1850
+ const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
1851
+ if (args.action === "publish") {
1852
+ const publishResult = await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
1853
+ if (publishResult.exitCode !== 0) {
1854
+ const detail = formatGitFailure(publishResult)
1855
+ const normalized = detail.toLowerCase()
1856
+ let title = "Publish branch failed"
1857
+ let message = summarizeGitFailure(detail, "Git could not publish this branch.")
1858
+
1859
+ if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
1860
+ title = "Remote authentication failed"
1861
+ message = "Git could not authenticate with the remote repository."
1862
+ }
1863
+
1864
+ return {
1865
+ ok: false,
1866
+ action: args.action,
1867
+ title,
1868
+ message,
1869
+ detail,
1870
+ snapshotChanged: false,
1871
+ }
1872
+ }
1873
+
1874
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1875
+ const branchName = await getBranchName(repo.repoRoot)
1876
+ const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
1877
+ const { aheadCount, behindCount } = nextHasUpstream
1878
+ ? await getUpstreamStatusCounts(repo.repoRoot)
1879
+ : { aheadCount: undefined, behindCount: undefined }
1880
+
1881
+ return {
1882
+ ok: true,
1883
+ action: args.action,
1884
+ branchName,
1885
+ aheadCount,
1886
+ behindCount,
1887
+ snapshotChanged,
1888
+ }
1889
+ }
1890
+
1891
+ if (args.action === "push") {
1892
+ if (!hasUpstream) {
1893
+ return {
1894
+ ok: false,
1895
+ action: args.action,
1896
+ title: "Push failed",
1897
+ message: "This branch does not have an upstream remote branch configured yet.",
1898
+ snapshotChanged: false,
1899
+ }
1900
+ }
1901
+
1902
+ const pushResult = await runGit(["push"], repo.repoRoot)
1903
+ if (pushResult.exitCode !== 0) {
1904
+ const detail = formatGitFailure(pushResult)
1905
+ return createSyncPushFailure(detail, false)
1906
+ }
1907
+
1908
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1909
+ const branchName = await getBranchName(repo.repoRoot)
1910
+ const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
1911
+ const { aheadCount, behindCount } = nextHasUpstream
1912
+ ? await getUpstreamStatusCounts(repo.repoRoot)
1913
+ : { aheadCount: undefined, behindCount: undefined }
1914
+
1915
+ return {
1916
+ ok: true,
1917
+ action: args.action,
1918
+ branchName,
1919
+ aheadCount,
1920
+ behindCount,
1921
+ snapshotChanged,
1922
+ }
1923
+ }
1924
+
1925
+ if (args.action === "pull" && !hasUpstream) {
1926
+ return {
1927
+ ok: false,
1928
+ action: args.action,
1929
+ title: "Pull failed",
1930
+ message: "This branch does not have an upstream remote branch configured yet.",
1931
+ snapshotChanged: false,
1932
+ }
1933
+ }
1934
+
1935
+ const syncResult = args.action === "pull"
1936
+ ? await runGit(["pull", "--ff-only"], repo.repoRoot)
1937
+ : await runGit(["fetch", "--all", "--prune"], repo.repoRoot)
1938
+
1939
+ if (syncResult.exitCode !== 0) {
1940
+ const detail = formatGitFailure(syncResult)
1941
+ const normalized = detail.toLowerCase()
1942
+ let title = args.action === "pull" ? "Pull failed" : "Fetch failed"
1943
+ let message = summarizeGitFailure(detail, args.action === "pull" ? "Git could not pull the latest changes." : "Git could not fetch the latest changes.")
1944
+
1945
+ if (args.action === "pull" && normalized.includes("not possible to fast-forward")) {
1946
+ title = "Pull requires merge or rebase"
1947
+ message = "Your branch cannot be fast-forwarded. Rebase or merge manually, then try again."
1948
+ } else if (normalized.includes("could not read from remote repository") || normalized.includes("authentication failed") || normalized.includes("permission denied")) {
1949
+ title = "Remote authentication failed"
1950
+ message = "Git could not authenticate with the remote repository."
1951
+ }
1952
+
1953
+ return {
1954
+ ok: false,
1955
+ action: args.action,
1956
+ title,
1957
+ message,
1958
+ detail,
1959
+ snapshotChanged: false,
1960
+ }
1961
+ }
1962
+
1963
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
1964
+ const branchName = await getBranchName(repo.repoRoot)
1965
+ const nextHasUpstream = await hasUpstreamBranch(repo.repoRoot)
1966
+ const { aheadCount, behindCount } = nextHasUpstream
1967
+ ? await getUpstreamStatusCounts(repo.repoRoot)
1968
+ : { aheadCount: undefined, behindCount: undefined }
1969
+
1970
+ return {
1971
+ ok: true,
1972
+ action: args.action,
1973
+ branchName,
1974
+ aheadCount,
1975
+ behindCount,
1976
+ snapshotChanged,
1977
+ }
1978
+ }
1979
+
1980
+ async generateCommitMessage(args: {
1981
+ projectPath: string
1982
+ paths: string[]
1983
+ }) {
1984
+ const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
1985
+ if (normalizedPaths.length === 0) {
1986
+ throw new Error("Select at least one file")
1987
+ }
1988
+
1989
+ const repo = await resolveRepo(args.projectPath)
1990
+ if (!repo) {
1991
+ throw new Error("Project is not in a git repository")
1992
+ }
1993
+
1994
+ const currentDirtyPaths = await listDirtyPaths(repo.repoRoot)
1995
+ const selectedFiles = await Promise.all(normalizedPaths.map(async (selectedPath) => {
1996
+ const entry = currentDirtyPaths.find((candidate) => candidate.path === selectedPath)
1997
+ if (!entry) {
1998
+ throw new Error(`File is no longer changed: ${selectedPath}`)
1999
+ }
2000
+
2001
+ const beforePath = entry.previousPath ?? selectedPath
2002
+ const beforeText = await readBaseFile(repo.repoRoot, repo.baseCommit, beforePath)
2003
+ const afterText = await readWorktreeFile(repo.repoRoot, selectedPath)
2004
+ const patch = await createPatch(beforePath, selectedPath, beforeText, afterText)
2005
+
2006
+ return {
2007
+ path: selectedPath,
2008
+ changeType: entry.changeType,
2009
+ patch,
2010
+ }
2011
+ }))
2012
+
2013
+ const branchName = await getBranchName(repo.repoRoot)
2014
+ return await generateCommitMessageDetailed({
2015
+ cwd: repo.repoRoot,
2016
+ branchName,
2017
+ files: selectedFiles,
2018
+ })
2019
+ }
2020
+
2021
+ async commitFiles(args: {
2022
+ projectId: string
2023
+ projectPath: string
2024
+ paths: string[]
2025
+ summary: string
2026
+ description?: string
2027
+ mode: DiffCommitMode
2028
+ }) {
2029
+ const summary = args.summary.trim()
2030
+ const description = args.description?.trim()
2031
+ if (!summary) {
2032
+ throw new Error("Commit summary is required")
2033
+ }
2034
+
2035
+ const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
2036
+ if (normalizedPaths.length === 0) {
2037
+ throw new Error("Select at least one file to commit")
2038
+ }
2039
+
2040
+ const repo = await resolveRepo(args.projectPath)
2041
+ if (!repo) {
2042
+ throw new Error("Project is not in a git repository")
2043
+ }
2044
+ const [hasUpstream, originRemoteUrl] = await Promise.all([
2045
+ hasUpstreamBranch(repo.repoRoot),
2046
+ getOriginRemoteUrl(repo.repoRoot),
2047
+ ])
2048
+ const hasOriginRemote = originRemoteUrl !== null
2049
+
2050
+ const currentDirtyEntries = await listDirtyPaths(repo.repoRoot)
2051
+ const currentDirtyPathsByPath = new Map(currentDirtyEntries.map((entry) => [entry.path, entry]))
2052
+ const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.has(relativePath))
2053
+ if (missingPaths.length > 0) {
2054
+ throw new Error(`File is no longer changed: ${missingPaths[0]}`)
2055
+ }
2056
+
2057
+ const trackedPaths = normalizedPaths.filter((relativePath) => !currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2058
+ if (trackedPaths.length > 0) {
2059
+ const addTrackedResult = await runGit(["add", "-u", "--", ...trackedPaths], repo.repoRoot)
2060
+ if (addTrackedResult.exitCode !== 0) {
2061
+ return createCommitFailure(args.mode, formatGitFailure(addTrackedResult))
2062
+ }
2063
+ }
2064
+
2065
+ const untrackedPaths = normalizedPaths.filter((relativePath) => currentDirtyPathsByPath.get(relativePath)?.isUntracked)
2066
+ if (untrackedPaths.length > 0) {
2067
+ const addUntrackedResult = await runGit(["add", "--", ...untrackedPaths], repo.repoRoot)
2068
+ if (addUntrackedResult.exitCode !== 0) {
2069
+ return createCommitFailure(args.mode, formatGitFailure(addUntrackedResult))
2070
+ }
2071
+ }
2072
+
2073
+ const commitArgs = ["commit", "--only", "-m", summary]
2074
+ if (description) {
2075
+ commitArgs.push("-m", description)
2076
+ }
2077
+ commitArgs.push("--", ...normalizedPaths)
2078
+
2079
+ const commitResult = await runGit(commitArgs, repo.repoRoot)
2080
+ if (commitResult.exitCode !== 0) {
2081
+ return createCommitFailure(args.mode, formatGitFailure(commitResult))
2082
+ }
2083
+
2084
+ const snapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
2085
+ const branchName = await getBranchName(repo.repoRoot)
2086
+
2087
+ if (args.mode === "commit_only") {
2088
+ return {
2089
+ ok: true,
2090
+ mode: args.mode,
2091
+ branchName,
2092
+ pushed: false,
2093
+ snapshotChanged,
2094
+ } satisfies DiffCommitResult
2095
+ }
2096
+
2097
+ if (!hasUpstream && !hasOriginRemote) {
2098
+ return {
2099
+ ok: true,
2100
+ mode: args.mode,
2101
+ branchName,
2102
+ pushed: false,
2103
+ snapshotChanged,
2104
+ } satisfies DiffCommitResult
2105
+ }
2106
+
2107
+ const pushResult = hasUpstream
2108
+ ? await runGit(["push"], repo.repoRoot)
2109
+ : await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
2110
+ if (pushResult.exitCode !== 0) {
2111
+ return createPushFailure(args.mode, formatGitFailure(pushResult), snapshotChanged)
2112
+ }
2113
+
2114
+ const postPushSnapshotChanged = await this.refreshSnapshot(args.projectId, args.projectPath)
2115
+
2116
+ return {
2117
+ ok: true,
2118
+ mode: args.mode,
2119
+ branchName,
2120
+ pushed: true,
2121
+ snapshotChanged: snapshotChanged || postPushSnapshotChanged,
2122
+ } satisfies DiffCommitResult
2123
+ }
2124
+
2125
+ async discardFile(args: {
2126
+ projectId: string
2127
+ projectPath: string
2128
+ path: string
2129
+ }) {
2130
+ const relativePath = normalizeRepoRelativePath(args.path)
2131
+ const repo = await resolveRepo(args.projectPath)
2132
+ if (!repo) {
2133
+ throw new Error("Project is not in a git repository")
2134
+ }
2135
+
2136
+ const entry = await findDirtyPath(repo.repoRoot, relativePath)
2137
+ if (!entry) {
2138
+ throw new Error(`File is no longer changed: ${relativePath}`)
2139
+ }
2140
+
2141
+ if (entry.isUntracked) {
2142
+ await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
2143
+ } else if (entry.changeType === "added") {
2144
+ await discardAddedPath(repo.repoRoot, repo.baseCommit !== null, entry.path)
2145
+ await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
2146
+ } else if (entry.changeType === "renamed") {
2147
+ if (!repo.baseCommit) {
2148
+ throw new Error("Cannot discard a rename before the repository has an initial commit")
2149
+ }
2150
+ await discardRenamedPath(repo.repoRoot, entry)
2151
+ } else {
2152
+ if (!repo.baseCommit) {
2153
+ throw new Error("Cannot discard tracked changes before the repository has an initial commit")
2154
+ }
2155
+ const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.path], repo.repoRoot)
2156
+ if (restoreResult.exitCode !== 0) {
2157
+ throw new Error(formatGitFailure(restoreResult) || "Failed to discard file changes")
2158
+ }
2159
+ }
2160
+
2161
+ return {
2162
+ snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
2163
+ }
2164
+ }
2165
+
2166
+ async ignoreFile(args: {
2167
+ projectId: string
2168
+ projectPath: string
2169
+ path: string
2170
+ }) {
2171
+ const ignoreEntry = normalizeRepoRelativePath(args.path)
2172
+ const repo = await resolveRepo(args.projectPath)
2173
+ if (!repo) {
2174
+ throw new Error("Project is not in a git repository")
2175
+ }
2176
+
2177
+ const dirtyPaths = await listDirtyPaths(repo.repoRoot)
2178
+ const exactEntry = dirtyPaths.find((candidate) => candidate.path === ignoreEntry)
2179
+ if (exactEntry && !exactEntry.isUntracked) {
2180
+ throw new Error("Only untracked files can be ignored from the diff viewer")
2181
+ }
2182
+
2183
+ const entry = dirtyPaths.find((candidate) => candidate.isUntracked && (candidate.path === ignoreEntry || candidate.path.startsWith(ignoreEntry)))
2184
+ if (!entry) {
2185
+ throw new Error(`File is no longer changed: ${ignoreEntry}`)
2186
+ }
2187
+
2188
+ const gitignorePath = path.join(repo.repoRoot, ".gitignore")
2189
+ const currentContents = await readFile(gitignorePath, "utf8").catch(() => null)
2190
+ const nextContents = appendGitIgnoreEntry(currentContents, ignoreEntry)
2191
+ if (nextContents !== currentContents) {
2192
+ await writeFile(gitignorePath, nextContents, "utf8")
2193
+ }
2194
+
2195
+ return {
2196
+ snapshotChanged: await this.refreshSnapshot(args.projectId, args.projectPath),
2197
+ }
2198
+ }
2199
+ }