kanna-code 0.20.0 → 0.21.1
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/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/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-BsILdq_R.js +2502 -0
- package/dist/client/assets/index-C_DLHgnt.css +32 -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/index.html +2 -2
- package/package.json +2 -1
- package/src/server/agent.test.ts +45 -1
- package/src/server/agent.ts +141 -8
- package/src/server/codex-app-server-protocol.ts +28 -0
- package/src/server/codex-app-server.test.ts +88 -0
- package/src/server/codex-app-server.ts +71 -1
- package/src/server/diff-store.test.ts +126 -0
- package/src/server/diff-store.ts +365 -0
- package/src/server/generate-commit-message.test.ts +59 -0
- package/src/server/generate-commit-message.ts +121 -0
- package/src/server/read-models.test.ts +42 -1
- package/src/server/read-models.ts +10 -3
- package/src/server/server.ts +60 -1
- package/src/server/terminal-manager.test.ts +37 -4
- package/src/server/uploads.ts +4 -0
- package/src/server/ws-router.ts +122 -7
- package/src/shared/protocol.ts +3 -0
- package/src/shared/types.ts +50 -0
- package/dist/client/assets/index-Cdjl9mRd.js +0 -606
- package/dist/client/assets/index-D6aACRxb.css +0 -32
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { DiffStore } from "./diff-store"
|
|
6
|
+
|
|
7
|
+
async function run(command: string[], cwd: string) {
|
|
8
|
+
const process = Bun.spawn(command, {
|
|
9
|
+
cwd,
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
})
|
|
13
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
14
|
+
new Response(process.stdout).text(),
|
|
15
|
+
new Response(process.stderr).text(),
|
|
16
|
+
process.exited,
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
if (exitCode !== 0) {
|
|
20
|
+
throw new Error(stderr || stdout || `Command failed: ${command.join(" ")}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return stdout
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createRepo() {
|
|
27
|
+
const root = await mkdtemp(path.join(tmpdir(), "kanna-diff-store-"))
|
|
28
|
+
await run(["git", "init"], root)
|
|
29
|
+
await run(["git", "config", "user.email", "kanna@example.com"], root)
|
|
30
|
+
await run(["git", "config", "user.name", "Kanna"], root)
|
|
31
|
+
return root
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tempDirs: string[] = []
|
|
35
|
+
|
|
36
|
+
describe("DiffStore", () => {
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("returns current worktree diffs for modified files", async () => {
|
|
42
|
+
const repoRoot = await createRepo()
|
|
43
|
+
tempDirs.push(repoRoot)
|
|
44
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
45
|
+
await run(["git", "add", "."], repoRoot)
|
|
46
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
47
|
+
await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
|
|
48
|
+
|
|
49
|
+
const store = new DiffStore(repoRoot)
|
|
50
|
+
await store.initialize()
|
|
51
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
52
|
+
|
|
53
|
+
const snapshot = store.getSnapshot("chat-1")
|
|
54
|
+
expect(snapshot.status).toBe("ready")
|
|
55
|
+
expect(snapshot.files).toHaveLength(1)
|
|
56
|
+
expect(snapshot.files[0]?.path).toBe("app.txt")
|
|
57
|
+
expect(snapshot.files[0]?.patch).toContain("-base")
|
|
58
|
+
expect(snapshot.files[0]?.patch).toContain("+changed")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("returns no_repo outside a git repository", async () => {
|
|
62
|
+
const root = await mkdtemp(path.join(tmpdir(), "kanna-no-repo-"))
|
|
63
|
+
tempDirs.push(root)
|
|
64
|
+
|
|
65
|
+
const store = new DiffStore(root)
|
|
66
|
+
await store.initialize()
|
|
67
|
+
await store.refreshSnapshot("chat-1", root)
|
|
68
|
+
|
|
69
|
+
expect(store.getSnapshot("chat-1")).toEqual({
|
|
70
|
+
status: "no_repo",
|
|
71
|
+
branchName: undefined,
|
|
72
|
+
files: [],
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("commits only the selected files and refreshes the snapshot", async () => {
|
|
77
|
+
const repoRoot = await createRepo()
|
|
78
|
+
tempDirs.push(repoRoot)
|
|
79
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
80
|
+
await writeFile(path.join(repoRoot, "notes.txt"), "keep\n", "utf8")
|
|
81
|
+
await run(["git", "add", "."], repoRoot)
|
|
82
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
83
|
+
|
|
84
|
+
await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
|
|
85
|
+
await writeFile(path.join(repoRoot, "notes.txt"), "changed too\n", "utf8")
|
|
86
|
+
|
|
87
|
+
const store = new DiffStore(repoRoot)
|
|
88
|
+
await store.initialize()
|
|
89
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
90
|
+
|
|
91
|
+
await store.commitFiles({
|
|
92
|
+
chatId: "chat-1",
|
|
93
|
+
projectPath: repoRoot,
|
|
94
|
+
paths: ["app.txt"],
|
|
95
|
+
summary: "Update app",
|
|
96
|
+
description: "Only app changes",
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const snapshot = store.getSnapshot("chat-1")
|
|
100
|
+
expect(snapshot.status).toBe("ready")
|
|
101
|
+
expect(snapshot.files).toHaveLength(1)
|
|
102
|
+
expect(snapshot.files[0]?.path).toBe("notes.txt")
|
|
103
|
+
|
|
104
|
+
const lastMessage = (await run(["git", "log", "-1", "--pretty=%B"], repoRoot)).trim()
|
|
105
|
+
expect(lastMessage).toBe("Update app\n\nOnly app changes")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("detects renamed files", async () => {
|
|
109
|
+
const repoRoot = await createRepo()
|
|
110
|
+
tempDirs.push(repoRoot)
|
|
111
|
+
await writeFile(path.join(repoRoot, "before.txt"), "same\n", "utf8")
|
|
112
|
+
await run(["git", "add", "."], repoRoot)
|
|
113
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
114
|
+
await run(["git", "mv", "before.txt", "after.txt"], repoRoot)
|
|
115
|
+
|
|
116
|
+
const store = new DiffStore(repoRoot)
|
|
117
|
+
await store.initialize()
|
|
118
|
+
await store.refreshSnapshot("chat-1", repoRoot)
|
|
119
|
+
|
|
120
|
+
const snapshot = store.getSnapshot("chat-1")
|
|
121
|
+
expect(snapshot.status).toBe("ready")
|
|
122
|
+
expect(snapshot.files).toHaveLength(1)
|
|
123
|
+
expect(snapshot.files[0]?.path).toBe("after.txt")
|
|
124
|
+
expect(snapshot.files[0]?.changeType).toBe("renamed")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import type { ChatDiffFile, ChatDiffSnapshot } from "../shared/types"
|
|
5
|
+
import { generateCommitMessageDetailed } from "./generate-commit-message"
|
|
6
|
+
import { inferProjectFileContentType } from "./uploads"
|
|
7
|
+
|
|
8
|
+
interface StoredChatDiffState {
|
|
9
|
+
status: ChatDiffSnapshot["status"]
|
|
10
|
+
branchName?: string
|
|
11
|
+
files: ChatDiffFile[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createEmptyState(): StoredChatDiffState {
|
|
15
|
+
return {
|
|
16
|
+
status: "unknown",
|
|
17
|
+
branchName: undefined,
|
|
18
|
+
files: [],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function snapshotsEqual(left: StoredChatDiffState | undefined, right: StoredChatDiffState) {
|
|
23
|
+
if (!left) {
|
|
24
|
+
return right.status === "unknown" && right.files.length === 0
|
|
25
|
+
}
|
|
26
|
+
if (left.status !== right.status) return false
|
|
27
|
+
if (left.branchName !== right.branchName) return false
|
|
28
|
+
if (left.files.length !== right.files.length) return false
|
|
29
|
+
return left.files.every((file, index) => {
|
|
30
|
+
const other = right.files[index]
|
|
31
|
+
return Boolean(other)
|
|
32
|
+
&& file.path === other.path
|
|
33
|
+
&& file.changeType === other.changeType
|
|
34
|
+
&& file.patch === other.patch
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DirtyPathEntry {
|
|
39
|
+
path: string
|
|
40
|
+
previousPath?: string
|
|
41
|
+
changeType: ChatDiffFile["changeType"]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fileExists(filePath: string) {
|
|
45
|
+
try {
|
|
46
|
+
await stat(filePath)
|
|
47
|
+
return true
|
|
48
|
+
} catch {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runGit(args: string[], cwd: string) {
|
|
54
|
+
const process = Bun.spawn(["git", "-C", cwd, ...args], {
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "pipe",
|
|
57
|
+
})
|
|
58
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
59
|
+
new Response(process.stdout).text(),
|
|
60
|
+
new Response(process.stderr).text(),
|
|
61
|
+
process.exited,
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
stdout,
|
|
66
|
+
stderr,
|
|
67
|
+
exitCode,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveRepo(projectPath: string): Promise<{ repoRoot: string; baseCommit: string | null } | null> {
|
|
72
|
+
const topLevel = await runGit(["rev-parse", "--show-toplevel"], projectPath)
|
|
73
|
+
if (topLevel.exitCode !== 0) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const repoRoot = topLevel.stdout.trim()
|
|
78
|
+
const head = await runGit(["rev-parse", "--verify", "HEAD"], repoRoot)
|
|
79
|
+
return {
|
|
80
|
+
repoRoot,
|
|
81
|
+
baseCommit: head.exitCode === 0 ? head.stdout.trim() : null,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function getBranchName(repoRoot: string) {
|
|
86
|
+
const symbolicRef = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot)
|
|
87
|
+
if (symbolicRef.exitCode === 0) {
|
|
88
|
+
return symbolicRef.stdout.trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const revParse = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot)
|
|
92
|
+
if (revParse.exitCode === 0) {
|
|
93
|
+
return revParse.stdout.trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseStatusPaths(output: string): DirtyPathEntry[] {
|
|
100
|
+
const entries: DirtyPathEntry[] = []
|
|
101
|
+
for (const rawLine of output.split(/\r?\n/u)) {
|
|
102
|
+
const line = rawLine.trimEnd()
|
|
103
|
+
if (line.length < 4) continue
|
|
104
|
+
const statusCode = line.slice(0, 2)
|
|
105
|
+
const value = line.slice(3)
|
|
106
|
+
if (!value) continue
|
|
107
|
+
const isRename = statusCode.includes("R")
|
|
108
|
+
const isDelete = statusCode.includes("D")
|
|
109
|
+
const isAdd = statusCode.includes("A") || statusCode === "??"
|
|
110
|
+
const changeType: ChatDiffFile["changeType"] = isRename
|
|
111
|
+
? "renamed"
|
|
112
|
+
: isDelete
|
|
113
|
+
? "deleted"
|
|
114
|
+
: isAdd
|
|
115
|
+
? "added"
|
|
116
|
+
: "modified"
|
|
117
|
+
|
|
118
|
+
if (isRename && value.includes(" -> ")) {
|
|
119
|
+
const [previousPath, nextPath] = value.split(" -> ")
|
|
120
|
+
if (nextPath) {
|
|
121
|
+
entries.push({
|
|
122
|
+
path: nextPath,
|
|
123
|
+
previousPath: previousPath || undefined,
|
|
124
|
+
changeType,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
entries.push({
|
|
131
|
+
path: value,
|
|
132
|
+
changeType,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function listDirtyPaths(repoRoot: string) {
|
|
139
|
+
const status = await runGit(["status", "--short", "--untracked-files=all"], repoRoot)
|
|
140
|
+
if (status.exitCode !== 0) {
|
|
141
|
+
throw new Error(status.stderr.trim() || "Failed to read git status")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const paths = parseStatusPaths(status.stdout)
|
|
145
|
+
return paths
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readWorktreeFile(repoRoot: string, relativePath: string): Promise<string | null> {
|
|
149
|
+
const absolutePath = path.join(repoRoot, relativePath)
|
|
150
|
+
if (!(await fileExists(absolutePath))) {
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return await readFile(absolutePath, "utf8")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function readBaseFile(repoRoot: string, baseCommit: string | null, relativePath: string): Promise<string | null> {
|
|
158
|
+
if (!baseCommit) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = await runGit(["show", `${baseCommit}:${relativePath}`], repoRoot)
|
|
163
|
+
if (result.exitCode !== 0) {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
return result.stdout
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function createPatch(beforePathLabel: string, afterPathLabel: string, beforeText: string | null, afterText: string | null) {
|
|
170
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), "kanna-diff-"))
|
|
171
|
+
const beforePath = path.join(tempDir, "before")
|
|
172
|
+
const afterPath = path.join(tempDir, "after")
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await writeFile(beforePath, beforeText ?? "", "utf8")
|
|
176
|
+
await writeFile(afterPath, afterText ?? "", "utf8")
|
|
177
|
+
|
|
178
|
+
const result = await runGit(
|
|
179
|
+
[
|
|
180
|
+
"diff",
|
|
181
|
+
"--no-index",
|
|
182
|
+
"--no-ext-diff",
|
|
183
|
+
"--text",
|
|
184
|
+
"--unified=3",
|
|
185
|
+
"--src-prefix=a/",
|
|
186
|
+
"--dst-prefix=b/",
|
|
187
|
+
"before",
|
|
188
|
+
"after",
|
|
189
|
+
],
|
|
190
|
+
tempDir
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
194
|
+
throw new Error(result.stderr.trim() || `Failed to build patch for ${afterPathLabel}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result.stdout
|
|
198
|
+
.replace("diff --git a/before b/after", `diff --git a/${beforePathLabel} b/${afterPathLabel}`)
|
|
199
|
+
.replace("--- a/before", `--- a/${beforePathLabel}`)
|
|
200
|
+
.replace("+++ b/after", `+++ b/${afterPathLabel}`)
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function computeCurrentFiles(repoRoot: string, baseCommit: string | null): Promise<ChatDiffFile[]> {
|
|
207
|
+
const currentDirtyPaths = await listDirtyPaths(repoRoot)
|
|
208
|
+
const files: ChatDiffFile[] = []
|
|
209
|
+
|
|
210
|
+
for (const entry of currentDirtyPaths) {
|
|
211
|
+
const relativePath = entry.path
|
|
212
|
+
const beforePath = entry.previousPath ?? relativePath
|
|
213
|
+
const beforeText = await readBaseFile(repoRoot, baseCommit, beforePath)
|
|
214
|
+
const afterText = await readWorktreeFile(repoRoot, relativePath)
|
|
215
|
+
const absolutePath = path.join(repoRoot, relativePath)
|
|
216
|
+
const fileInfo = await stat(absolutePath).catch(() => null)
|
|
217
|
+
const file = fileInfo?.isFile() ? Bun.file(absolutePath) : null
|
|
218
|
+
const mimeType = file ? inferProjectFileContentType(relativePath, file.type) : undefined
|
|
219
|
+
const size = fileInfo?.isFile() ? fileInfo.size : undefined
|
|
220
|
+
|
|
221
|
+
if (beforeText === afterText && entry.changeType !== "renamed") {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const patch = await createPatch(beforePath, relativePath, beforeText, afterText)
|
|
226
|
+
files.push({
|
|
227
|
+
path: relativePath,
|
|
228
|
+
changeType: entry.changeType,
|
|
229
|
+
patch,
|
|
230
|
+
mimeType,
|
|
231
|
+
size,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return files
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeRepoRelativePath(inputPath: string) {
|
|
239
|
+
const normalized = path.posix.normalize(inputPath.replaceAll("\\", "/")).replace(/^\.\/+/u, "")
|
|
240
|
+
if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../") || path.posix.isAbsolute(normalized)) {
|
|
241
|
+
throw new Error(`Invalid diff path: ${inputPath}`)
|
|
242
|
+
}
|
|
243
|
+
return normalized
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export class DiffStore {
|
|
247
|
+
private readonly states = new Map<string, StoredChatDiffState>()
|
|
248
|
+
|
|
249
|
+
constructor(_: string) {}
|
|
250
|
+
|
|
251
|
+
async initialize() {}
|
|
252
|
+
|
|
253
|
+
getSnapshot(chatId: string): ChatDiffSnapshot {
|
|
254
|
+
const state = this.states.get(chatId) ?? createEmptyState()
|
|
255
|
+
return {
|
|
256
|
+
status: state.status,
|
|
257
|
+
branchName: state.branchName,
|
|
258
|
+
files: [...state.files],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async refreshSnapshot(chatId: string, projectPath: string) {
|
|
263
|
+
const repo = await resolveRepo(projectPath)
|
|
264
|
+
if (!repo) {
|
|
265
|
+
const nextState = {
|
|
266
|
+
status: "no_repo",
|
|
267
|
+
branchName: undefined,
|
|
268
|
+
files: [],
|
|
269
|
+
} satisfies StoredChatDiffState
|
|
270
|
+
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
271
|
+
this.states.set(chatId, nextState)
|
|
272
|
+
return changed
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
|
|
276
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
277
|
+
const nextState = {
|
|
278
|
+
status: "ready",
|
|
279
|
+
branchName,
|
|
280
|
+
files,
|
|
281
|
+
} satisfies StoredChatDiffState
|
|
282
|
+
const changed = !snapshotsEqual(this.states.get(chatId), nextState)
|
|
283
|
+
this.states.set(chatId, nextState)
|
|
284
|
+
return changed
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async generateCommitMessage(args: {
|
|
288
|
+
projectPath: string
|
|
289
|
+
paths: string[]
|
|
290
|
+
}) {
|
|
291
|
+
const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
|
|
292
|
+
if (normalizedPaths.length === 0) {
|
|
293
|
+
throw new Error("Select at least one file")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const repo = await resolveRepo(args.projectPath)
|
|
297
|
+
if (!repo) {
|
|
298
|
+
throw new Error("Project is not in a git repository")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const files = await computeCurrentFiles(repo.repoRoot, repo.baseCommit)
|
|
302
|
+
const selectedFiles = normalizedPaths.map((selectedPath) => {
|
|
303
|
+
const file = files.find((candidate) => candidate.path === selectedPath)
|
|
304
|
+
if (!file) {
|
|
305
|
+
throw new Error(`File is no longer changed: ${selectedPath}`)
|
|
306
|
+
}
|
|
307
|
+
return file
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const branchName = await getBranchName(repo.repoRoot)
|
|
311
|
+
return await generateCommitMessageDetailed({
|
|
312
|
+
cwd: repo.repoRoot,
|
|
313
|
+
branchName,
|
|
314
|
+
files: selectedFiles,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async commitFiles(args: {
|
|
319
|
+
chatId: string
|
|
320
|
+
projectPath: string
|
|
321
|
+
paths: string[]
|
|
322
|
+
summary: string
|
|
323
|
+
description?: string
|
|
324
|
+
}) {
|
|
325
|
+
const summary = args.summary.trim()
|
|
326
|
+
const description = args.description?.trim()
|
|
327
|
+
if (!summary) {
|
|
328
|
+
throw new Error("Commit summary is required")
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const normalizedPaths = [...new Set(args.paths.map(normalizeRepoRelativePath))]
|
|
332
|
+
if (normalizedPaths.length === 0) {
|
|
333
|
+
throw new Error("Select at least one file to commit")
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const repo = await resolveRepo(args.projectPath)
|
|
337
|
+
if (!repo) {
|
|
338
|
+
throw new Error("Project is not in a git repository")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
|
|
342
|
+
const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
|
|
343
|
+
if (missingPaths.length > 0) {
|
|
344
|
+
throw new Error(`File is no longer changed: ${missingPaths[0]}`)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const addResult = await runGit(["add", "--", ...normalizedPaths], repo.repoRoot)
|
|
348
|
+
if (addResult.exitCode !== 0) {
|
|
349
|
+
throw new Error(addResult.stderr.trim() || "Failed to stage selected files")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const commitArgs = ["commit", "--only", "-m", summary]
|
|
353
|
+
if (description) {
|
|
354
|
+
commitArgs.push("-m", description)
|
|
355
|
+
}
|
|
356
|
+
commitArgs.push("--", ...normalizedPaths)
|
|
357
|
+
|
|
358
|
+
const commitResult = await runGit(commitArgs, repo.repoRoot)
|
|
359
|
+
if (commitResult.exitCode !== 0) {
|
|
360
|
+
throw new Error(commitResult.stderr.trim() || commitResult.stdout.trim() || "Failed to commit selected files")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return await this.refreshSnapshot(args.chatId, args.projectPath)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { QuickResponseAdapter } from "./quick-response"
|
|
3
|
+
import { generateCommitMessageDetailed } from "./generate-commit-message"
|
|
4
|
+
|
|
5
|
+
describe("generateCommitMessageDetailed", () => {
|
|
6
|
+
test("returns sanitized generated subject and body", async () => {
|
|
7
|
+
const result = await generateCommitMessageDetailed(
|
|
8
|
+
{
|
|
9
|
+
cwd: "/tmp/project",
|
|
10
|
+
branchName: "feature/test",
|
|
11
|
+
files: [{
|
|
12
|
+
path: "app.ts",
|
|
13
|
+
changeType: "modified",
|
|
14
|
+
patch: "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@\n-old\n+new\n",
|
|
15
|
+
}],
|
|
16
|
+
},
|
|
17
|
+
new QuickResponseAdapter({
|
|
18
|
+
runClaudeStructured: async () => ({
|
|
19
|
+
subject: " Add login UI.\nextra line",
|
|
20
|
+
body: " - wire form\n- add validation ",
|
|
21
|
+
}),
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
subject: "Add login UI",
|
|
27
|
+
body: "- wire form\n- add validation",
|
|
28
|
+
usedFallback: false,
|
|
29
|
+
failureMessage: null,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("falls back when providers fail", async () => {
|
|
34
|
+
const result = await generateCommitMessageDetailed(
|
|
35
|
+
{
|
|
36
|
+
cwd: "/tmp/project",
|
|
37
|
+
branchName: "feature/test",
|
|
38
|
+
files: [{
|
|
39
|
+
path: "src/feature.ts",
|
|
40
|
+
changeType: "modified",
|
|
41
|
+
patch: "diff --git a/src/feature.ts b/src/feature.ts\n",
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
new QuickResponseAdapter({
|
|
45
|
+
runClaudeStructured: async () => {
|
|
46
|
+
throw new Error("Not authenticated")
|
|
47
|
+
},
|
|
48
|
+
runCodexStructured: async () => null,
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({
|
|
53
|
+
subject: "Update feature.ts",
|
|
54
|
+
body: "",
|
|
55
|
+
usedFallback: true,
|
|
56
|
+
failureMessage: "claude failed commit message generation: Not authenticated; codex returned no result for commit message generation",
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import type { ChatDiffFile } from "../shared/types"
|
|
3
|
+
import { QuickResponseAdapter } from "./quick-response"
|
|
4
|
+
|
|
5
|
+
const COMMIT_MESSAGE_SCHEMA = {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {
|
|
8
|
+
subject: { type: "string" },
|
|
9
|
+
body: { type: "string" },
|
|
10
|
+
},
|
|
11
|
+
required: ["subject", "body"],
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export interface GenerateCommitMessageResult {
|
|
16
|
+
subject: string
|
|
17
|
+
body: string
|
|
18
|
+
usedFallback: boolean
|
|
19
|
+
failureMessage: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
|
|
23
|
+
if (failures.length === 0) return null
|
|
24
|
+
return failures.map((failure) => failure.reason).join("; ")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function limitText(value: string, maxLength: number) {
|
|
28
|
+
return value.length <= maxLength ? value : `${value.slice(0, maxLength).trimEnd()}\n...[truncated]`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sanitizeSubject(value: unknown): string | null {
|
|
32
|
+
if (typeof value !== "string") return null
|
|
33
|
+
const normalized = (value.split(/\r?\n/u)[0] ?? "")
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(/[.]+$/u, "")
|
|
37
|
+
.slice(0, 72)
|
|
38
|
+
.trim()
|
|
39
|
+
return normalized.length > 0 ? normalized : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeBody(value: unknown): string {
|
|
43
|
+
if (typeof value !== "string") return ""
|
|
44
|
+
return value.trim()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fallbackSubject(files: ChatDiffFile[]) {
|
|
48
|
+
if (files.length === 1) {
|
|
49
|
+
const fileName = path.posix.basename(files[0]?.path ?? "file")
|
|
50
|
+
const normalized = `Update ${fileName}`.replace(/\s+/g, " ").trim()
|
|
51
|
+
return normalized.slice(0, 72).trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `Update ${files.length} files`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildCommitMessagePrompt(args: {
|
|
58
|
+
branchName?: string
|
|
59
|
+
files: ChatDiffFile[]
|
|
60
|
+
}) {
|
|
61
|
+
const fileList = args.files.map((file) => `${file.changeType}: ${file.path}`).join("\n")
|
|
62
|
+
const combinedPatch = args.files.map((file) => file.patch).join("\n\n")
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
"Generate a concise git commit message for the selected changes.",
|
|
66
|
+
"Return JSON with keys: subject, body.",
|
|
67
|
+
"Rules:",
|
|
68
|
+
"- subject must be imperative, under 72 chars, and have no trailing period",
|
|
69
|
+
"- body may be an empty string or short bullet points",
|
|
70
|
+
"- capture the primary user-visible or developer-visible change",
|
|
71
|
+
"",
|
|
72
|
+
`Branch: ${args.branchName ?? "current branch"}`,
|
|
73
|
+
"",
|
|
74
|
+
"Selected files:",
|
|
75
|
+
limitText(fileList, 6_000),
|
|
76
|
+
"",
|
|
77
|
+
"Selected patch:",
|
|
78
|
+
limitText(combinedPatch, 40_000),
|
|
79
|
+
].join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function generateCommitMessageDetailed(
|
|
83
|
+
args: {
|
|
84
|
+
cwd: string
|
|
85
|
+
branchName?: string
|
|
86
|
+
files: ChatDiffFile[]
|
|
87
|
+
},
|
|
88
|
+
adapter = new QuickResponseAdapter()
|
|
89
|
+
): Promise<GenerateCommitMessageResult> {
|
|
90
|
+
const result = await adapter.generateStructuredWithDiagnostics<{ subject: string; body: string }>({
|
|
91
|
+
cwd: args.cwd,
|
|
92
|
+
task: "commit message generation",
|
|
93
|
+
prompt: buildCommitMessagePrompt(args),
|
|
94
|
+
schema: COMMIT_MESSAGE_SCHEMA,
|
|
95
|
+
parse: (value) => {
|
|
96
|
+
const output = value && typeof value === "object" ? value as { subject?: unknown; body?: unknown } : {}
|
|
97
|
+
const subject = sanitizeSubject(output.subject)
|
|
98
|
+
if (!subject) return null
|
|
99
|
+
return {
|
|
100
|
+
subject,
|
|
101
|
+
body: sanitizeBody(output.body),
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (result.value) {
|
|
107
|
+
return {
|
|
108
|
+
subject: result.value.subject,
|
|
109
|
+
body: result.value.body,
|
|
110
|
+
usedFallback: false,
|
|
111
|
+
failureMessage: null,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
subject: fallbackSubject(args.files),
|
|
117
|
+
body: "",
|
|
118
|
+
usedFallback: true,
|
|
119
|
+
failureMessage: summarizeFailures(result.failures),
|
|
120
|
+
}
|
|
121
|
+
}
|