@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.
- package/LICENSE +22 -0
- package/README.md +355 -0
- package/bin/kanna +9 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/abap-BdImnpbu.js +1 -0
- package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
- package/dist/client/assets/ada-bCR0ucgS.js +1 -0
- package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
- package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
- package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
- package/dist/client/assets/apache-Pmp26Uib.js +1 -0
- package/dist/client/assets/apex-D8_7TLub.js +1 -0
- package/dist/client/assets/apl-dKokRX4l.js +1 -0
- package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
- package/dist/client/assets/ara-BRHolxvo.js +1 -0
- package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
- package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
- package/dist/client/assets/astro-CbQHKStN.js +1 -0
- package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
- package/dist/client/assets/awk-DMzUqQB5.js +1 -0
- package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
- package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
- package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
- package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
- package/dist/client/assets/bat-BkioyH1T.js +1 -0
- package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
- package/dist/client/assets/berry-uYugtg8r.js +1 -0
- package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
- package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
- package/dist/client/assets/bird2-DPOp833l.js +1 -0
- package/dist/client/assets/blade-D4QpJJKB.js +1 -0
- package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
- package/dist/client/assets/c-BIGW1oBm.js +1 -0
- package/dist/client/assets/c3-eo99z4R2.js +1 -0
- package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
- package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
- package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- package/dist/client/assets/clarity-D53aC0YG.js +1 -0
- package/dist/client/assets/clojure-P80f7IUj.js +1 -0
- package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
- package/dist/client/assets/cobol-nwyudZeR.js +1 -0
- package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
- package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
- package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
- package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
- package/dist/client/assets/coq-DkFqJrB1.js +1 -0
- package/dist/client/assets/cpp-CofmeUqb.js +1 -0
- package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
- package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
- package/dist/client/assets/css-DPfMkruS.js +1 -0
- package/dist/client/assets/csv-fuZLfV_i.js +1 -0
- package/dist/client/assets/cue-D82EKSYY.js +1 -0
- package/dist/client/assets/cypher-COkxafJQ.js +1 -0
- package/dist/client/assets/d-85-TOEBH.js +1 -0
- package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
- package/dist/client/assets/dart-CF10PKvl.js +1 -0
- package/dist/client/assets/dax-CEL-wOlO.js +1 -0
- package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
- package/dist/client/assets/diff-D97Zzqfu.js +1 -0
- package/dist/client/assets/docker-BcOcwvcX.js +1 -0
- package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
- package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
- package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
- package/dist/client/assets/edge-BkV0erSs.js +1 -0
- package/dist/client/assets/elixir-CDX3lj18.js +1 -0
- package/dist/client/assets/elm-DbKCFpqz.js +1 -0
- package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
- package/dist/client/assets/erb-B12qg9BL.js +1 -0
- package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
- package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
- package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
- package/dist/client/assets/fennel-BYunw83y.js +1 -0
- package/dist/client/assets/fish-BvzEVeQv.js +1 -0
- package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
- package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
- package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
- package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
- package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
- package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
- package/dist/client/assets/genie-D0YGMca9.js +1 -0
- package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
- package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
- package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
- package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
- package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
- package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- package/dist/client/assets/gleam-BspZqrRM.js +1 -0
- package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
- package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
- package/dist/client/assets/glsl-DplSGwfg.js +1 -0
- package/dist/client/assets/gn-n2N0HUVH.js +1 -0
- package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
- package/dist/client/assets/go-CxLEBnE3.js +1 -0
- package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
- package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- package/dist/client/assets/hack-CaT9iCJl.js +1 -0
- package/dist/client/assets/haml-B8DHNrY2.js +1 -0
- package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
- package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
- package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
- package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
- package/dist/client/assets/hjson-D5-asLiD.js +1 -0
- package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
- package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
- package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
- package/dist/client/assets/houston-DnULxvSX.js +1 -0
- package/dist/client/assets/html-GMplVEZG.js +1 -0
- package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
- package/dist/client/assets/http-jrhK8wxY.js +1 -0
- package/dist/client/assets/hurl-irOxFIW8.js +1 -0
- package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
- package/dist/client/assets/hy-DFXneXwc.js +1 -0
- package/dist/client/assets/imba-DGztddWO.js +1 -0
- package/dist/client/assets/index-Do7324M0.css +32 -0
- package/dist/client/assets/index-ktE9DLCD.js +2620 -0
- package/dist/client/assets/ini-BEwlwnbL.js +1 -0
- package/dist/client/assets/java-CylS5w8V.js +1 -0
- package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
- package/dist/client/assets/jison-wvAkD_A8.js +1 -0
- package/dist/client/assets/json-Cp-IABpG.js +1 -0
- package/dist/client/assets/json5-C9tS-k6U.js +1 -0
- package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
- package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
- package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
- package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
- package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/client/assets/julia-CxzCAyBv.js +1 -0
- package/dist/client/assets/just-Cw27pwNe.js +1 -0
- package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
- package/dist/client/assets/kdl-DV7GczEv.js +1 -0
- package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/client/assets/kusto-DZf3V79B.js +1 -0
- package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
- package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
- package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
- package/dist/client/assets/less-B1dDrJ26.js +1 -0
- package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
- package/dist/client/assets/liquid-DYVedYrR.js +1 -0
- package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
- package/dist/client/assets/log-2UxHyX5q.js +1 -0
- package/dist/client/assets/logo-BtOb2qkB.js +1 -0
- package/dist/client/assets/lua-BaeVxFsk.js +1 -0
- package/dist/client/assets/luau-C-HG3fhB.js +1 -0
- package/dist/client/assets/make-CHLpvVh8.js +1 -0
- package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/client/assets/marko-CnJfTvn9.js +1 -0
- package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
- package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
- package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
- package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- package/dist/client/assets/matlab-D7o27uSR.js +1 -0
- package/dist/client/assets/mdc-BMNejdWA.js +1 -0
- package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
- package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
- package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
- package/dist/client/assets/min-light-CTRr51gU.js +1 -0
- package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
- package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
- package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
- package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
- package/dist/client/assets/move-IF9eRakj.js +1 -0
- package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
- package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
- package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
- package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
- package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
- package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
- package/dist/client/assets/nim-CVrawwO9.js +1 -0
- package/dist/client/assets/nix-CwoSXNpI.js +1 -0
- package/dist/client/assets/nord-Ddv68eIx.js +1 -0
- package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
- package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
- package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
- package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
- package/dist/client/assets/odin-BBf5iR-q.js +1 -0
- package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
- package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
- package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
- package/dist/client/assets/perl-C0TMdlhV.js +1 -0
- package/dist/client/assets/php-Dhbhpdrm.js +1 -0
- package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
- package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
- package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
- package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
- package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
- package/dist/client/assets/po-BTJTHyun.js +1 -0
- package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
- package/dist/client/assets/polar-C0HS_06l.js +1 -0
- package/dist/client/assets/postcss-CXtECtnM.js +1 -0
- package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
- package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
- package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
- package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
- package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
- package/dist/client/assets/pug-CGlum2m_.js +1 -0
- package/dist/client/assets/puppet-BMWR74SV.js +1 -0
- package/dist/client/assets/purescript-CklMAg4u.js +1 -0
- package/dist/client/assets/python-B6aJPvgy.js +1 -0
- package/dist/client/assets/qml-3beO22l8.js +1 -0
- package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
- package/dist/client/assets/qss-IeuSbFQv.js +1 -0
- package/dist/client/assets/r-Dspwwk_N.js +1 -0
- package/dist/client/assets/racket-BqYA7rlc.js +1 -0
- package/dist/client/assets/raku-DXvB9xmW.js +1 -0
- package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
- package/dist/client/assets/red-bN70gL4F.js +1 -0
- package/dist/client/assets/reg-C-SQnVFl.js +1 -0
- package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
- package/dist/client/assets/rel-C3B-1QV4.js +1 -0
- package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
- package/dist/client/assets/ron-D8l8udqQ.js +1 -0
- package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
- package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
- package/dist/client/assets/rst-BrH8l1NY.js +1 -0
- package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
- package/dist/client/assets/rust-B1yitclQ.js +1 -0
- package/dist/client/assets/sas-cz2c8ADy.js +1 -0
- package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
- package/dist/client/assets/scala-C151Ov-r.js +1 -0
- package/dist/client/assets/scheme-C98Dy4si.js +1 -0
- package/dist/client/assets/scss-OYdSNvt2.js +1 -0
- package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
- package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
- package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
- package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
- package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
- package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
- package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
- package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
- package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
- package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
- package/dist/client/assets/solidity-rGO070M0.js +1 -0
- package/dist/client/assets/soy-Brmx7dQM.js +1 -0
- package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
- package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
- package/dist/client/assets/sql-BLtJtn59.js +1 -0
- package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
- package/dist/client/assets/stata-BH5u7GGu.js +1 -0
- package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
- package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
- package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
- package/dist/client/assets/swift-D82vCrfD.js +1 -0
- package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
- package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
- package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
- package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
- package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
- package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
- package/dist/client/assets/templ-P3uqSqPl.js +1 -0
- package/dist/client/assets/terraform-BETggiCN.js +1 -0
- package/dist/client/assets/tex-idrVyKtj.js +1 -0
- package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
- package/dist/client/assets/toml-vGWfd6FD.js +1 -0
- package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
- package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
- package/dist/client/assets/tsx-COt5Ahok.js +1 -0
- package/dist/client/assets/turtle-BsS91CYL.js +1 -0
- package/dist/client/assets/twig-DNn4PbVi.js +1 -0
- package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
- package/dist/client/assets/typst-DHCkPAjA.js +1 -0
- package/dist/client/assets/v-BcVCzyr7.js +1 -0
- package/dist/client/assets/vala-CsfeWuGM.js +1 -0
- package/dist/client/assets/vb-D17OF-Vu.js +1 -0
- package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
- package/dist/client/assets/vesper-DU1UobuO.js +1 -0
- package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
- package/dist/client/assets/viml-CJc9bBzg.js +1 -0
- package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
- package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/client/assets/vue-DN_0RTcg.js +1 -0
- package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
- package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
- package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
- package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
- package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
- package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
- package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
- package/dist/client/assets/wit-5i3qLPDT.js +1 -0
- package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
- package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
- package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
- package/dist/client/assets/yaml-Buea-lGh.js +1 -0
- package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
- package/dist/client/assets/zig-VOosw3JB.js +1 -0
- package/dist/client/chat-sounds/Blow.mp3 +0 -0
- package/dist/client/chat-sounds/Bottle.mp3 +0 -0
- package/dist/client/chat-sounds/Frog.mp3 +0 -0
- package/dist/client/chat-sounds/Funk.mp3 +0 -0
- package/dist/client/chat-sounds/Glass.mp3 +0 -0
- package/dist/client/chat-sounds/Ping.mp3 +0 -0
- package/dist/client/chat-sounds/Pop.mp3 +0 -0
- package/dist/client/chat-sounds/Purr.mp3 +0 -0
- package/dist/client/chat-sounds/Tink.mp3 +0 -0
- package/dist/client/editor-icons/cursor.png +0 -0
- package/dist/client/editor-icons/custom.png +0 -0
- package/dist/client/editor-icons/default-app.png +0 -0
- package/dist/client/editor-icons/finder.png +0 -0
- package/dist/client/editor-icons/preview.png +0 -0
- package/dist/client/editor-icons/terminal.png +0 -0
- package/dist/client/editor-icons/windsurf.png +0 -0
- package/dist/client/editor-icons/xcode.png +0 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/icon.svg +4 -0
- package/dist/client/index.html +34 -0
- package/dist/client/manifest.webmanifest +46 -0
- package/dist/client/screenshot-light.png +0 -0
- package/dist/client/screenshot.png +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
- package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
- package/dist/export-viewer/editor-icons/cursor.png +0 -0
- package/dist/export-viewer/editor-icons/custom.png +0 -0
- package/dist/export-viewer/editor-icons/default-app.png +0 -0
- package/dist/export-viewer/editor-icons/finder.png +0 -0
- package/dist/export-viewer/editor-icons/preview.png +0 -0
- package/dist/export-viewer/editor-icons/terminal.png +0 -0
- package/dist/export-viewer/editor-icons/windsurf.png +0 -0
- package/dist/export-viewer/editor-icons/xcode.png +0 -0
- package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
- package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
- package/dist/export-viewer/index.html +14 -0
- package/package.json +99 -0
- package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
- package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
- package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
- package/src/server/agent.test.ts +2369 -0
- package/src/server/agent.ts +1927 -0
- package/src/server/analytics.test.ts +313 -0
- package/src/server/analytics.ts +131 -0
- package/src/server/app-settings.test.ts +233 -0
- package/src/server/app-settings.ts +548 -0
- package/src/server/auth.test.ts +329 -0
- package/src/server/auth.ts +204 -0
- package/src/server/auto-continue/e2e.test.ts +215 -0
- package/src/server/auto-continue/events.test.ts +30 -0
- package/src/server/auto-continue/events.ts +35 -0
- package/src/server/auto-continue/limit-detector.test.ts +153 -0
- package/src/server/auto-continue/limit-detector.ts +159 -0
- package/src/server/auto-continue/read-model.test.ts +109 -0
- package/src/server/auto-continue/read-model.ts +83 -0
- package/src/server/auto-continue/schedule-manager.test.ts +155 -0
- package/src/server/auto-continue/schedule-manager.ts +116 -0
- package/src/server/claude-session-importer.test.ts +214 -0
- package/src/server/claude-session-importer.ts +187 -0
- package/src/server/claude-session-mapper.test.ts +88 -0
- package/src/server/claude-session-mapper.ts +106 -0
- package/src/server/claude-session-parser.test.ts +38 -0
- package/src/server/claude-session-parser.ts +67 -0
- package/src/server/claude-session-scanner.test.ts +49 -0
- package/src/server/claude-session-scanner.ts +24 -0
- package/src/server/claude-session-types.ts +61 -0
- package/src/server/cli-runtime.test.ts +523 -0
- package/src/server/cli-runtime.ts +405 -0
- package/src/server/cli-supervisor.ts +102 -0
- package/src/server/cli.ts +64 -0
- package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
- package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
- package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
- package/src/server/cloudflare-tunnel/detector.ts +44 -0
- package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
- package/src/server/cloudflare-tunnel/events.test.ts +43 -0
- package/src/server/cloudflare-tunnel/events.ts +31 -0
- package/src/server/cloudflare-tunnel/gateway.ts +143 -0
- package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
- package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
- package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
- package/src/server/cloudflare-tunnel/read-model.ts +80 -0
- package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
- package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
- package/src/server/codex-app-server-protocol.ts +487 -0
- package/src/server/codex-app-server.test.ts +1816 -0
- package/src/server/codex-app-server.ts +1475 -0
- package/src/server/diff-store.test.ts +737 -0
- package/src/server/diff-store.ts +2199 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +301 -0
- package/src/server/event-store.test.ts +797 -0
- package/src/server/event-store.ts +1421 -0
- package/src/server/events.ts +217 -0
- package/src/server/external-open.test.ts +112 -0
- package/src/server/external-open.ts +345 -0
- package/src/server/generate-commit-message.test.ts +79 -0
- package/src/server/generate-commit-message.ts +126 -0
- package/src/server/generate-title.ts +76 -0
- package/src/server/harness-types.ts +19 -0
- package/src/server/keybindings.test.ts +144 -0
- package/src/server/keybindings.ts +178 -0
- package/src/server/llm-provider.test.ts +134 -0
- package/src/server/llm-provider.ts +207 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths-route.test.ts +64 -0
- package/src/server/paths.ts +35 -0
- package/src/server/process-utils.test.ts +12 -0
- package/src/server/process-utils.ts +47 -0
- package/src/server/project-paths.test.ts +95 -0
- package/src/server/project-paths.ts +191 -0
- package/src/server/provider-catalog.test.ts +69 -0
- package/src/server/provider-catalog.ts +87 -0
- package/src/server/quick-response.test.ts +440 -0
- package/src/server/quick-response.ts +300 -0
- package/src/server/read-models.test.ts +509 -0
- package/src/server/read-models.ts +230 -0
- package/src/server/restart.test.ts +27 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +616 -0
- package/src/server/share.test.ts +180 -0
- package/src/server/share.ts +150 -0
- package/src/server/standalone-export.test.ts +224 -0
- package/src/server/standalone-export.ts +419 -0
- package/src/server/terminal-manager.test.ts +315 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/test-helpers/async-event-queue.ts +52 -0
- package/src/server/test-helpers/wait-for.ts +14 -0
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/update-manager.test.ts +158 -0
- package/src/server/update-manager.ts +222 -0
- package/src/server/update-strategy.test.ts +237 -0
- package/src/server/update-strategy.ts +241 -0
- package/src/server/uploads.test.ts +292 -0
- package/src/server/uploads.ts +131 -0
- package/src/server/ws-router.test.ts +2292 -0
- package/src/server/ws-router.ts +1465 -0
- package/src/shared/analytics.ts +30 -0
- package/src/shared/branding.test.ts +31 -0
- package/src/shared/branding.ts +77 -0
- package/src/shared/dev-ports.test.ts +113 -0
- package/src/shared/dev-ports.ts +134 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +257 -0
- package/src/shared/share.ts +27 -0
- package/src/shared/tools.test.ts +164 -0
- package/src/shared/tools.ts +327 -0
- package/src/shared/types.test.ts +25 -0
- 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
|
+
}
|