@pi-chamber/web 0.1.8
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/README.md +43 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +140 -0
- package/bin/cli-output.test.js +36 -0
- package/bin/cli.js +383 -0
- package/bin/cli.test.js +1065 -0
- package/bin/lib/DOCUMENTATION.md +31 -0
- package/bin/lib/cli-api-target.js +74 -0
- package/bin/lib/cli-args.js +846 -0
- package/bin/lib/cli-errors.js +18 -0
- package/bin/lib/cli-http.js +252 -0
- package/bin/lib/cli-lifecycle.js +421 -0
- package/bin/lib/cli-log-files.js +93 -0
- package/bin/lib/cli-network.js +186 -0
- package/bin/lib/cli-paths.js +128 -0
- package/bin/lib/cli-ports.js +51 -0
- package/bin/lib/cli-process.js +342 -0
- package/bin/lib/cli-startup.js +371 -0
- package/bin/lib/cli-tunnel-capabilities.js +9 -0
- package/bin/lib/cli-tunnel-profiles.js +366 -0
- package/bin/lib/cli-tunnel-utils.js +300 -0
- package/bin/lib/commands-connect-url.js +329 -0
- package/bin/lib/commands-lifecycle.js +396 -0
- package/bin/lib/commands-logs.js +110 -0
- package/bin/lib/commands-serve.js +419 -0
- package/bin/lib/commands-startup.js +64 -0
- package/bin/lib/commands-status.js +114 -0
- package/bin/lib/commands-tunnel.js +1761 -0
- package/bin/lib/commands-update.js +184 -0
- package/bin/lib/commands-update.test.js +98 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +13 -0
- package/dist/assets/CommitSection-DGGArAb2.js +1 -0
- package/dist/assets/DiagramEditor-ig5nYV6t.js +1 -0
- package/dist/assets/DiagramView-8Qr6pwwI.js +1 -0
- package/dist/assets/DiffView-BHS4m_K7.js +18 -0
- package/dist/assets/FilesView-DmZcdwl-.js +14 -0
- package/dist/assets/GitView-gfHxmc66.js +6 -0
- package/dist/assets/JsonTreeView-DbFqka3P.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-B07lHhBl.js +12 -0
- package/dist/assets/PatchDiff-DD9pAs47.js +1 -0
- package/dist/assets/PierreDiffViewer-Bo0_BCT7.js +136 -0
- package/dist/assets/SessionSwitcherDropdown-t7WZX24f.js +1 -0
- package/dist/assets/SettingsView-CtsS4oDI.js +3 -0
- package/dist/assets/SettingsWindow-DIRGn3eQ.js +1 -0
- package/dist/assets/TerminalView-BV3KOtdA.css +1 -0
- package/dist/assets/TerminalView-DUwO1CCY.js +46 -0
- package/dist/assets/ToolOutputDialog-iZG2ey4T.js +15 -0
- package/dist/assets/ToolPartDiffPreview-FTexlZSz.js +8 -0
- package/dist/assets/VirtualizedFileDiff-DD5IznIe.js +10 -0
- package/dist/assets/Virtualizer-BxW2CJGB.js +1 -0
- package/dist/assets/abap-BdImnpbu.js +1 -0
- package/dist/assets/abap-Dg923wIy.js +1 -0
- package/dist/assets/actionscript-3-8WTK4YD2.js +1 -0
- package/dist/assets/actionscript-3-CoDkCxhg.js +1 -0
- package/dist/assets/ada-CTi7YQtL.js +1 -0
- package/dist/assets/ada-bCR0ucgS.js +1 -0
- package/dist/assets/andromeeda-C4gqWexZ.js +1 -0
- package/dist/assets/andromeeda-CT0vplkN.js +1 -0
- package/dist/assets/angular-html-CU67Zn6k.js +1 -0
- package/dist/assets/angular-html-D2R1pe45.js +1 -0
- package/dist/assets/angular-ts-BDmYQnhm.js +1 -0
- package/dist/assets/angular-ts-BwZT4LLn.js +1 -0
- package/dist/assets/apache-PcO-DBxQ.js +1 -0
- package/dist/assets/apache-Pmp26Uib.js +1 -0
- package/dist/assets/apex-CnCu5_gb.js +1 -0
- package/dist/assets/apex-D8_7TLub.js +1 -0
- package/dist/assets/apl-B4CMkyY2.js +1 -0
- package/dist/assets/apl-DkfyvC6M.js +1 -0
- package/dist/assets/apl-dKokRX4l.js +1 -0
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +1 -0
- package/dist/assets/applescript-Co6uUVPk.js +1 -0
- package/dist/assets/applescript-Ds_W2jPS.js +1 -0
- package/dist/assets/ara-BRHolxvo.js +1 -0
- package/dist/assets/ara-C1--7J61.js +1 -0
- package/dist/assets/asciiarmor-Df11BRmG.js +1 -0
- package/dist/assets/asciidoc-Ve4PFQV2.js +1 -0
- package/dist/assets/asciidoc-nTfEFGxb.js +1 -0
- package/dist/assets/asm-CTFRnVOM.js +1 -0
- package/dist/assets/asm-D_Q5rh1f.js +1 -0
- package/dist/assets/asn1-EdZsLKOL.js +1 -0
- package/dist/assets/asterisk-B-8jnY81.js +1 -0
- package/dist/assets/astro-CbQHKStN.js +1 -0
- package/dist/assets/astro-DtPAdMss.js +1 -0
- package/dist/assets/aurora-x-C0VKFdlO.js +1 -0
- package/dist/assets/aurora-x-D-2ljcwZ.js +1 -0
- package/dist/assets/awk-CrWgQKhV.js +1 -0
- package/dist/assets/awk-DMzUqQB5.js +1 -0
- package/dist/assets/ayu-dark-DYE7WIF3.js +1 -0
- package/dist/assets/ayu-dark-wZ6ORhBL.js +1 -0
- package/dist/assets/ayu-light-BA47KaF1.js +1 -0
- package/dist/assets/ayu-light-xXLJ_Ch1.js +1 -0
- package/dist/assets/ayu-mirage-32ctXXKs.js +1 -0
- package/dist/assets/ayu-mirage-CQckA_sP.js +1 -0
- package/dist/assets/ballerina-BFfxhgS-.js +1 -0
- package/dist/assets/ballerina-a0yOSyc5.js +1 -0
- package/dist/assets/bat-B0cHNp6s.js +1 -0
- package/dist/assets/bat-BkioyH1T.js +1 -0
- package/dist/assets/beancount-dPJi_gci.js +1 -0
- package/dist/assets/beancount-k_qm7-4y.js +1 -0
- package/dist/assets/berry-CVKoklHx.js +1 -0
- package/dist/assets/berry-uYugtg8r.js +1 -0
- package/dist/assets/bibtex-CHM0blh-.js +1 -0
- package/dist/assets/bibtex-DRcCXNgM.js +1 -0
- package/dist/assets/bicep-Bmn6On1c.js +1 -0
- package/dist/assets/bicep-jWz-hQ8I.js +1 -0
- package/dist/assets/bird2-DPOp833l.js +1 -0
- package/dist/assets/bird2-DaGXIRGu.js +1 -0
- package/dist/assets/blade-D4QpJJKB.js +1 -0
- package/dist/assets/blade-QlWdzBxO.js +1 -0
- package/dist/assets/brainfuck-C4LP7Hcl.js +1 -0
- package/dist/assets/bsl-BO_Y6i37.js +1 -0
- package/dist/assets/bsl-jNujDQK2.js +1 -0
- package/dist/assets/c-BG-tZi7o.js +1 -0
- package/dist/assets/c-BIGW1oBm.js +1 -0
- package/dist/assets/c3-Fcb8_HKm.js +1 -0
- package/dist/assets/c3-eo99z4R2.js +1 -0
- package/dist/assets/cadence-BuaeHeSu.js +1 -0
- package/dist/assets/cadence-Bv_4Rxtq.js +1 -0
- package/dist/assets/cairo-BYiDgOF2.js +1 -0
- package/dist/assets/cairo-KRGpt6FW.js +1 -0
- package/dist/assets/catppuccin-frappe-CfssSZMA.js +1 -0
- package/dist/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- package/dist/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- package/dist/assets/catppuccin-latte-DoBaWTGn.js +1 -0
- package/dist/assets/catppuccin-macchiato-BkAuvdZC.js +1 -0
- package/dist/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- package/dist/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- package/dist/assets/catppuccin-mocha-D9fcC0iT.js +1 -0
- package/dist/assets/clarity-CC_0IFDj.js +1 -0
- package/dist/assets/clarity-D53aC0YG.js +1 -0
- package/dist/assets/clike-B9uivgTg.js +1 -0
- package/dist/assets/clojure-BMjYHr_A.js +1 -0
- package/dist/assets/clojure-KnfDkB_V.js +1 -0
- package/dist/assets/clojure-P80f7IUj.js +1 -0
- package/dist/assets/cmake-BQqOBYOt.js +1 -0
- package/dist/assets/cmake-D1j8_8rp.js +1 -0
- package/dist/assets/cmake-bh2MLnkv.js +1 -0
- package/dist/assets/cobol-CWcv1MsR.js +1 -0
- package/dist/assets/cobol-dIXRVV9Y.js +1 -0
- package/dist/assets/cobol-nwyudZeR.js +1 -0
- package/dist/assets/codeowners-Bp6g37R7.js +1 -0
- package/dist/assets/codeowners-CtwIM3TN.js +1 -0
- package/dist/assets/codeql-D164juXQ.js +1 -0
- package/dist/assets/codeql-DsOJ9woJ.js +1 -0
- package/dist/assets/coffee-Bk-z6oI_.js +1 -0
- package/dist/assets/coffee-Ch7k5sss.js +1 -0
- package/dist/assets/coffeescript-S37ZYGWr.js +1 -0
- package/dist/assets/collapsible-ODWWxk4f.js +1 -0
- package/dist/assets/common-lisp-Cg-RD9OK.js +1 -0
- package/dist/assets/common-lisp-DXc9XcIM.js +1 -0
- package/dist/assets/commonlisp-DBKNyK5s.js +1 -0
- package/dist/assets/computeVirtualFileMetrics-DdXkLFAD.js +3 -0
- package/dist/assets/contextFileOpenGuard-uC4OerzL.js +1 -0
- package/dist/assets/coq-BBL5-mG4.js +1 -0
- package/dist/assets/coq-DkFqJrB1.js +1 -0
- package/dist/assets/cpp-C0lzDePx.js +1 -0
- package/dist/assets/cpp-CofmeUqb.js +1 -0
- package/dist/assets/crystal-Bhade0k2.js +1 -0
- package/dist/assets/crystal-SjHAIU92.js +1 -0
- package/dist/assets/crystal-tKQVLTB8.js +1 -0
- package/dist/assets/csharp-B7nLXyi8.js +1 -0
- package/dist/assets/csharp-COcwbKMJ.js +1 -0
- package/dist/assets/css-BnMrqG3P.js +1 -0
- package/dist/assets/css-DPfMkruS.js +1 -0
- package/dist/assets/css-PZBBrkI0.js +1 -0
- package/dist/assets/csv-D5aBhKLr.js +1 -0
- package/dist/assets/csv-fuZLfV_i.js +1 -0
- package/dist/assets/cue-D82EKSYY.js +1 -0
- package/dist/assets/cue-WCin6O4e.js +1 -0
- package/dist/assets/cypher-COkxafJQ.js +1 -0
- package/dist/assets/cypher-C_CwsFkJ.js +1 -0
- package/dist/assets/cypher-DYQk2G82.js +1 -0
- package/dist/assets/d-85-TOEBH.js +1 -0
- package/dist/assets/d-DVc-yF-l.js +1 -0
- package/dist/assets/d-pRatUO7H.js +1 -0
- package/dist/assets/dark-plus-C3mMm8J8.js +1 -0
- package/dist/assets/dark-plus-DLU5INA3.js +1 -0
- package/dist/assets/dart-CF10PKvl.js +1 -0
- package/dist/assets/dart-YLd5yyRs.js +1 -0
- package/dist/assets/dax-CEL-wOlO.js +1 -0
- package/dist/assets/dax-oldcCaJg.js +1 -0
- package/dist/assets/desktop-BmXAJ9_W.js +1 -0
- package/dist/assets/desktop-DZfyBMgF.js +1 -0
- package/dist/assets/diff-D97Zzqfu.js +1 -0
- package/dist/assets/diff-DbItnlRl.js +1 -0
- package/dist/assets/diff-jW5kmiru.js +1 -0
- package/dist/assets/docker-BcOcwvcX.js +1 -0
- package/dist/assets/docker-DG9neUKd.js +1 -0
- package/dist/assets/dockerfile-BKs6k2Af.js +1 -0
- package/dist/assets/document-attachments-BslE4Ns7.js +30 -0
- package/dist/assets/dotenv-Bea-FkXq.js +1 -0
- package/dist/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/assets/dracula-BzJJZx-M.js +1 -0
- package/dist/assets/dracula-XcYHBZy6.js +1 -0
- package/dist/assets/dracula-soft-BPeX9gxW.js +1 -0
- package/dist/assets/dracula-soft-BXkSAIEj.js +1 -0
- package/dist/assets/dream-maker-BtqSS_iP.js +1 -0
- package/dist/assets/dream-maker-DAHQwpwp.js +1 -0
- package/dist/assets/dtd-DF_7sFjM.js +1 -0
- package/dist/assets/dylan-DwRh75JA.js +1 -0
- package/dist/assets/ebnf-CDyGwa7X.js +1 -0
- package/dist/assets/ecl-Cabwm37j.js +1 -0
- package/dist/assets/edge-BkV0erSs.js +1 -0
- package/dist/assets/edge-zt3dU95k.js +1 -0
- package/dist/assets/eiffel-CnydiIhH.js +1 -0
- package/dist/assets/elixir-CDX3lj18.js +1 -0
- package/dist/assets/elixir-JolcG37e.js +1 -0
- package/dist/assets/elm-BjIDgKzz.js +1 -0
- package/dist/assets/elm-DbKCFpqz.js +1 -0
- package/dist/assets/elm-vLlmbW-K.js +1 -0
- package/dist/assets/emacs-lisp-C9XAeP06.js +1 -0
- package/dist/assets/emacs-lisp-DMBh55R4.js +1 -0
- package/dist/assets/erb-B12qg9BL.js +1 -0
- package/dist/assets/erb-rsjdL4fB.js +1 -0
- package/dist/assets/erlang-BNw1qcRV.js +1 -0
- package/dist/assets/erlang-DNeY46SW.js +1 -0
- package/dist/assets/erlang-DsQrWhSR.js +1 -0
- package/dist/assets/everforest-dark-BgDCqdQA.js +1 -0
- package/dist/assets/everforest-dark-WOub3wle.js +1 -0
- package/dist/assets/everforest-light-C8M2exoo.js +1 -0
- package/dist/assets/everforest-light-DCUg8VH8.js +1 -0
- package/dist/assets/factor-kuTfRLto.js +1 -0
- package/dist/assets/fcl-Kvtd6kyn.js +1 -0
- package/dist/assets/fennel-BYunw83y.js +1 -0
- package/dist/assets/fennel-DBMsxtrh.js +1 -0
- package/dist/assets/fish-BvzEVeQv.js +1 -0
- package/dist/assets/fish-BxskpYPN.js +1 -0
- package/dist/assets/fluent-Bwo2XpEt.js +1 -0
- package/dist/assets/fluent-C4IJs8-o.js +1 -0
- package/dist/assets/forth-Ffai-XNe.js +1 -0
- package/dist/assets/fortran-DYz_wnZ1.js +1 -0
- package/dist/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- package/dist/assets/fortran-fixed-form-DFFnVrrY.js +1 -0
- package/dist/assets/fortran-free-form-BxgE0vQu.js +1 -0
- package/dist/assets/fortran-free-form-DB5N_DgY.js +1 -0
- package/dist/assets/fsharp-CXgrBDvD.js +1 -0
- package/dist/assets/fsharp-DRAF1jsN.js +1 -0
- package/dist/assets/gas-Bneqetm1.js +1 -0
- package/dist/assets/gdresource-BD-1uzY7.js +1 -0
- package/dist/assets/gdresource-BOOCDP_w.js +1 -0
- package/dist/assets/gdscript-C3mT4Uym.js +1 -0
- package/dist/assets/gdscript-C5YyOfLZ.js +1 -0
- package/dist/assets/gdshader-DkwncUOv.js +1 -0
- package/dist/assets/gdshader-G7X6ysY3.js +1 -0
- package/dist/assets/genie-D0YGMca9.js +1 -0
- package/dist/assets/genie-DSsjfZMX.js +1 -0
- package/dist/assets/gherkin-Cl2Fw-_0.js +1 -0
- package/dist/assets/gherkin-DyxjwDmM.js +1 -0
- package/dist/assets/gherkin-heZmZLOM.js +1 -0
- package/dist/assets/git-commit-CJPsQObV.js +1 -0
- package/dist/assets/git-commit-F4YmCXRG.js +1 -0
- package/dist/assets/git-rebase-DeZqv4fp.js +1 -0
- package/dist/assets/git-rebase-r7XF79zn.js +1 -0
- package/dist/assets/github-dark-BQV5ugrc.js +1 -0
- package/dist/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/assets/github-dark-default-Cuk6v7N8.js +1 -0
- package/dist/assets/github-dark-default-DLWDM7FH.js +1 -0
- package/dist/assets/github-dark-dimmed-D1FmiR_p.js +1 -0
- package/dist/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- package/dist/assets/github-dark-high-contrast-BilLoi0o.js +1 -0
- package/dist/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- package/dist/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/assets/github-light-Rcb2q3L5.js +1 -0
- package/dist/assets/github-light-default-CQ75esMw.js +1 -0
- package/dist/assets/github-light-default-D7oLnXFd.js +1 -0
- package/dist/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- package/dist/assets/github-light-high-contrast-CRF1wyLy.js +1 -0
- package/dist/assets/gleam-BeBqoPwE.js +1 -0
- package/dist/assets/gleam-BspZqrRM.js +1 -0
- package/dist/assets/glimmer-js-BuyKUjfV.js +1 -0
- package/dist/assets/glimmer-js-Rg0-pVw9.js +1 -0
- package/dist/assets/glimmer-ts-D9iniPFN.js +1 -0
- package/dist/assets/glimmer-ts-U6CK756n.js +1 -0
- package/dist/assets/glsl-BOG9l2nV.js +1 -0
- package/dist/assets/glsl-DplSGwfg.js +1 -0
- package/dist/assets/gn-Dxr0sSUf.js +1 -0
- package/dist/assets/gn-n2N0HUVH.js +1 -0
- package/dist/assets/gnuplot-CtW3R_Hz.js +1 -0
- package/dist/assets/gnuplot-DdkO51Og.js +1 -0
- package/dist/assets/go-BBr2UQCn.js +1 -0
- package/dist/assets/go-CxLEBnE3.js +1 -0
- package/dist/assets/graphql-B6XHM4uj.js +1 -0
- package/dist/assets/graphql-ChdNCCLP.js +1 -0
- package/dist/assets/groovy-D9Dt4D0W.js +1 -0
- package/dist/assets/groovy-DFxE31Qs.js +1 -0
- package/dist/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/assets/gruvbox-dark-hard-C37G1gbc.js +1 -0
- package/dist/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- package/dist/assets/gruvbox-dark-medium-C3G9m3TV.js +1 -0
- package/dist/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- package/dist/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- package/dist/assets/gruvbox-dark-soft-eZmcmZ9I.js +1 -0
- package/dist/assets/gruvbox-light-hard-BYj-vz_3.js +1 -0
- package/dist/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- package/dist/assets/gruvbox-light-medium-BdQlDiiF.js +1 -0
- package/dist/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- package/dist/assets/gruvbox-light-soft-BGNwjiRh.js +1 -0
- package/dist/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- package/dist/assets/hack-CaT9iCJl.js +1 -0
- package/dist/assets/hack-Cxa-FaZ4.js +1 -0
- package/dist/assets/haml-2e0EI6Is.js +1 -0
- package/dist/assets/haml-B8DHNrY2.js +1 -0
- package/dist/assets/handlebars-BL8al0AC.js +1 -0
- package/dist/assets/handlebars-CojHdNmS.js +1 -0
- package/dist/assets/haskell-BfdhR_WN.js +1 -0
- package/dist/assets/haskell-Cw1EW3IL.js +1 -0
- package/dist/assets/haskell-Df6bDoY_.js +1 -0
- package/dist/assets/haxe-CzTSHFRz.js +1 -0
- package/dist/assets/haxe-DVIybFAS.js +1 -0
- package/dist/assets/haxe-H-WmDvRZ.js +1 -0
- package/dist/assets/hcl-B6OePqme.js +1 -0
- package/dist/assets/hcl-BWvSN4gD.js +1 -0
- package/dist/assets/hjson-D2jn7IRw.js +1 -0
- package/dist/assets/hjson-D5-asLiD.js +1 -0
- package/dist/assets/hlsl-D3lLCCz7.js +1 -0
- package/dist/assets/hlsl-MKeth5V9.js +1 -0
- package/dist/assets/horizon-BUw7H-hv.js +1 -0
- package/dist/assets/horizon-bright-Cn-bp-IR.js +1 -0
- package/dist/assets/horizon-bright-CoQRYDvy.js +1 -0
- package/dist/assets/horizon-cslQss18.js +1 -0
- package/dist/assets/houston-DnULxvSX.js +1 -0
- package/dist/assets/houston-RHIKkNfR.js +1 -0
- package/dist/assets/html-DOf0XnR2.js +1 -0
- package/dist/assets/html-GMplVEZG.js +1 -0
- package/dist/assets/html-derivative-BFtXZ54Q.js +1 -0
- package/dist/assets/html-derivative-DfCZ6Hio.js +1 -0
- package/dist/assets/http-CG3jvXcS.js +1 -0
- package/dist/assets/http-DBlCnlav.js +1 -0
- package/dist/assets/http-jrhK8wxY.js +1 -0
- package/dist/assets/hurl-CxQrh4RI.js +1 -0
- package/dist/assets/hurl-irOxFIW8.js +1 -0
- package/dist/assets/hxml-Bvhsp5Yf.js +1 -0
- package/dist/assets/hxml-CkS3Eie7.js +1 -0
- package/dist/assets/hy-DFXneXwc.js +1 -0
- package/dist/assets/hy-DI6mCUnw.js +1 -0
- package/dist/assets/idl-BEugSyMb.js +1 -0
- package/dist/assets/imba-D-Pk5S0X.js +1 -0
- package/dist/assets/imba-DGztddWO.js +1 -0
- package/dist/assets/index-CRB7i1S2.js +8 -0
- package/dist/assets/index-C_Mo87uS.css +1 -0
- package/dist/assets/index-MSKFS67y.js +1 -0
- package/dist/assets/ini-BEwlwnbL.js +1 -0
- package/dist/assets/ini-Dix3pTmg.js +1 -0
- package/dist/assets/isDiffPlainText-CeCQwHA2.js +1 -0
- package/dist/assets/isFilePlainText-BcfhE9FH.js +1 -0
- package/dist/assets/java-BTDmaRNO.js +1 -0
- package/dist/assets/java-CylS5w8V.js +1 -0
- package/dist/assets/javascript-BO_2ioGC.js +1 -0
- package/dist/assets/javascript-WyGyM1wV.js +1 -0
- package/dist/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/assets/jinja-4LBKfQ-Z.js +1 -0
- package/dist/assets/jinja-D0mZ3q2r.js +1 -0
- package/dist/assets/jison-B9XHrcYJ.js +1 -0
- package/dist/assets/jison-wvAkD_A8.js +1 -0
- package/dist/assets/json-B8JryHcM.js +1 -0
- package/dist/assets/json-Cp-IABpG.js +1 -0
- package/dist/assets/json5-C9tS-k6U.js +1 -0
- package/dist/assets/json5-Cdatmj9n.js +1 -0
- package/dist/assets/jsonc-CvUCa1o5.js +1 -0
- package/dist/assets/jsonc-Des-eS-w.js +1 -0
- package/dist/assets/jsonl-9I_GBd5L.js +1 -0
- package/dist/assets/jsonl-DcaNXYhu.js +1 -0
- package/dist/assets/jsonnet-DFQXde-d.js +1 -0
- package/dist/assets/jsonnet-XoenQ2oc.js +1 -0
- package/dist/assets/jssm-C2t-YnRu.js +1 -0
- package/dist/assets/jssm-CWE2S0Z5.js +1 -0
- package/dist/assets/jsx-8cLv2Six.js +1 -0
- package/dist/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/assets/julia-C1oINnwa.js +1 -0
- package/dist/assets/julia-CxzCAyBv.js +1 -0
- package/dist/assets/julia-DuME0IfC.js +1 -0
- package/dist/assets/just-Bo3fV9RY.js +1 -0
- package/dist/assets/just-Cw27pwNe.js +1 -0
- package/dist/assets/kanagawa-dragon-BOvIHJVT.js +1 -0
- package/dist/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- package/dist/assets/kanagawa-lotus-Bwcd7Bvx.js +1 -0
- package/dist/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- package/dist/assets/kanagawa-wave-0SAlpf0s.js +1 -0
- package/dist/assets/kanagawa-wave-DWedfzmr.js +1 -0
- package/dist/assets/kdl-CMbyIXjQ.js +1 -0
- package/dist/assets/kdl-DV7GczEv.js +1 -0
- package/dist/assets/kimi-for-coding-eVEHug_h.svg +3 -0
- package/dist/assets/kotlin-BQy537ds.js +1 -0
- package/dist/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/assets/kusto-B_y0uMaZ.js +1 -0
- package/dist/assets/kusto-DZf3V79B.js +1 -0
- package/dist/assets/laserwave-DUszq2jm.js +1 -0
- package/dist/assets/laserwave-si7wuSrt.js +1 -0
- package/dist/assets/latex-CWtU0Tv5.js +1 -0
- package/dist/assets/latex-CZAhoKip.js +1 -0
- package/dist/assets/lean-BZvkOJ9d.js +1 -0
- package/dist/assets/lean-C4RHkN41.js +1 -0
- package/dist/assets/less-B1dDrJ26.js +1 -0
- package/dist/assets/less-XNfgnZC7.js +1 -0
- package/dist/assets/light-plus-B7mTdjB0.js +1 -0
- package/dist/assets/light-plus-g_Fi9vkG.js +1 -0
- package/dist/assets/liquid-DFlqVvG0.js +1 -0
- package/dist/assets/liquid-DYVedYrR.js +1 -0
- package/dist/assets/livescript-BwQOo05w.js +1 -0
- package/dist/assets/llmapi-detailed-Pup_107f.svg +28 -0
- package/dist/assets/llvm-BJRUtCev.js +1 -0
- package/dist/assets/llvm-DjAJT7YJ.js +1 -0
- package/dist/assets/log-2UxHyX5q.js +1 -0
- package/dist/assets/log-CcaF_xNv.js +1 -0
- package/dist/assets/logo-BtOb2qkB.js +1 -0
- package/dist/assets/logo-D9WQLoKQ.js +1 -0
- package/dist/assets/lua-8Av-h-ns.js +1 -0
- package/dist/assets/lua-BaeVxFsk.js +1 -0
- package/dist/assets/lua-BgMRiT3U.js +1 -0
- package/dist/assets/luau-BTSFCcFb.js +1 -0
- package/dist/assets/luau-C-HG3fhB.js +1 -0
- package/dist/assets/main-CPygRDd9.js +134 -0
- package/dist/assets/main-DZg4qzOe.js +2 -0
- package/dist/assets/make-CHLpvVh8.js +1 -0
- package/dist/assets/make-DXVx14cr.js +1 -0
- package/dist/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/assets/markdown-DX-TNHnc.js +1 -0
- package/dist/assets/markdown-shiki.worker-DnvH1S1x.js +12 -0
- package/dist/assets/markdownCore-BULtt6Xa.js +2 -0
- package/dist/assets/marko-CnJfTvn9.js +1 -0
- package/dist/assets/marko-DiIoNBFw.js +1 -0
- package/dist/assets/material-theme-CIJ3cGaW.js +1 -0
- package/dist/assets/material-theme-D5KoaKCx.js +1 -0
- package/dist/assets/material-theme-darker-BWg3m1GB.js +1 -0
- package/dist/assets/material-theme-darker-BfHTSMKl.js +1 -0
- package/dist/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- package/dist/assets/material-theme-lighter-DR8HWMN7.js +1 -0
- package/dist/assets/material-theme-ocean-CZf9pk28.js +1 -0
- package/dist/assets/material-theme-ocean-CyktbL80.js +1 -0
- package/dist/assets/material-theme-palenight-BF01YeZy.js +1 -0
- package/dist/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- package/dist/assets/mathematica-DTrFuWx2.js +1 -0
- package/dist/assets/matlab-D7o27uSR.js +1 -0
- package/dist/assets/matlab-DzreyMvm.js +1 -0
- package/dist/assets/mbox-CNhZ1qSd.js +1 -0
- package/dist/assets/mdc-BMNejdWA.js +1 -0
- package/dist/assets/mdc-D_DpLax5.js +1 -0
- package/dist/assets/mdx-C2fqO2XY.js +1 -0
- package/dist/assets/mdx-Cmh6b_Ma.js +1 -0
- package/dist/assets/mermaid-Cwy4Up_p.js +1 -0
- package/dist/assets/mermaid-mWjccvbQ.js +1 -0
- package/dist/assets/min-dark-CafNBF8u.js +1 -0
- package/dist/assets/min-dark-DhfaepVE.js +1 -0
- package/dist/assets/min-light-6xx0uMgm.js +1 -0
- package/dist/assets/min-light-CTRr51gU.js +1 -0
- package/dist/assets/miniChat-BFotDgpO.js +2 -0
- package/dist/assets/mipsasm-CKIfxQSi.js +1 -0
- package/dist/assets/mipsasm-yE_Aqo9y.js +1 -0
- package/dist/assets/mirc-CjQqDB4T.js +1 -0
- package/dist/assets/mllike-BO34x3tv.js +1 -0
- package/dist/assets/mobile-3LLzhN5Q.js +2 -0
- package/dist/assets/modelica-Dc1JOy9r.js +1 -0
- package/dist/assets/mojo-M6pJPbMk.js +1 -0
- package/dist/assets/mojo-rZm6bMo-.js +1 -0
- package/dist/assets/monokai-BodnsaDQ.js +1 -0
- package/dist/assets/monokai-D4h5O-jR.js +1 -0
- package/dist/assets/moonbit-CR8uFLGd.js +1 -0
- package/dist/assets/moonbit-_H4v1dQx.js +1 -0
- package/dist/assets/move-CyYMWVy5.js +1 -0
- package/dist/assets/move-IF9eRakj.js +1 -0
- package/dist/assets/mscgen-BA5vi2Kp.js +1 -0
- package/dist/assets/mumps-BT43cFF4.js +1 -0
- package/dist/assets/narrat-DRg8JJMk.js +1 -0
- package/dist/assets/narrat-DXUZ_ZkW.js +1 -0
- package/dist/assets/nextflow-BPolriEg.js +1 -0
- package/dist/assets/nextflow-Zz6hmt5N.js +1 -0
- package/dist/assets/nextflow-groovy-BeH2EWoN.js +1 -0
- package/dist/assets/nextflow-groovy-pQRwk_Jg.js +1 -0
- package/dist/assets/nginx-BpAMiNFr.js +1 -0
- package/dist/assets/nginx-DSXkacNc.js +1 -0
- package/dist/assets/nginx-DdIZxoE0.js +1 -0
- package/dist/assets/night-owl-C39BiMTA.js +1 -0
- package/dist/assets/night-owl-EdP2Hztp.js +1 -0
- package/dist/assets/night-owl-light-Bk-lYVyk.js +1 -0
- package/dist/assets/night-owl-light-CMTm3GFP.js +1 -0
- package/dist/assets/nim-CVrawwO9.js +1 -0
- package/dist/assets/nim-e2z2kCcq.js +1 -0
- package/dist/assets/nix-Ckjrpg0r.js +1 -0
- package/dist/assets/nix-CwoSXNpI.js +1 -0
- package/dist/assets/nord-BAu0wM6c.js +1 -0
- package/dist/assets/nord-Ddv68eIx.js +1 -0
- package/dist/assets/nsis-LdVXkNf5.js +1 -0
- package/dist/assets/ntriples-BfvgReVJ.js +1 -0
- package/dist/assets/nushell-Cz2AlsmD.js +1 -0
- package/dist/assets/nushell-DtHLcZf1.js +1 -0
- package/dist/assets/objective-c-BoaBTdtz.js +1 -0
- package/dist/assets/objective-c-DXmwc3jG.js +1 -0
- package/dist/assets/objective-cpp-CLxacb5B.js +1 -0
- package/dist/assets/objective-cpp-DstR8hNy.js +1 -0
- package/dist/assets/ocaml-C0hk2d4L.js +1 -0
- package/dist/assets/ocaml-DTbn2Hv7.js +1 -0
- package/dist/assets/octave-CWZ4xMHY.js +1 -0
- package/dist/assets/odin-BBf5iR-q.js +1 -0
- package/dist/assets/odin-Bxc4S6Xn.js +1 -0
- package/dist/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- package/dist/assets/one-dark-pro-utPxbBbJ.js +1 -0
- package/dist/assets/one-light-BuCQ22aO.js +1 -0
- package/dist/assets/one-light-C3Wv6jpd.js +1 -0
- package/dist/assets/openscad-C4EeE6gA.js +1 -0
- package/dist/assets/openscad-CXHpNBsb.js +1 -0
- package/dist/assets/oz-BzwKVEFT.js +1 -0
- package/dist/assets/pascal--L3eBynH.js +1 -0
- package/dist/assets/pascal-D93ZcfNL.js +1 -0
- package/dist/assets/pascal-Sr-XLSE6.js +1 -0
- package/dist/assets/perl-C0TMdlhV.js +1 -0
- package/dist/assets/perl-C95KruZl.js +1 -0
- package/dist/assets/perl-CdXCOZ3F.js +1 -0
- package/dist/assets/permissionStore-BRcQ9Jgt.js +1 -0
- package/dist/assets/php-B4Kqya17.js +1 -0
- package/dist/assets/php-Dhbhpdrm.js +1 -0
- package/dist/assets/pig-CevX1Tat.js +1 -0
- package/dist/assets/pkl-CRusufhw.js +1 -0
- package/dist/assets/pkl-u5AG7uiY.js +1 -0
- package/dist/assets/plastic-3e1v2bzS.js +1 -0
- package/dist/assets/plastic-CXnNNlFZ.js +1 -0
- package/dist/assets/plsql-ChMvpjG-.js +1 -0
- package/dist/assets/plsql-Dbm3tV2N.js +1 -0
- package/dist/assets/po-BTJTHyun.js +1 -0
- package/dist/assets/po-LG0lFwH4.js +1 -0
- package/dist/assets/poimandres-BrOVMvuE.js +1 -0
- package/dist/assets/poimandres-CS3Unz2-.js +1 -0
- package/dist/assets/polar-C0HS_06l.js +1 -0
- package/dist/assets/polar-DjOs3GWb.js +1 -0
- package/dist/assets/postcss-CXtECtnM.js +1 -0
- package/dist/assets/postcss-GRRkWn2F.js +1 -0
- package/dist/assets/powerquery-CEu0bR-o.js +1 -0
- package/dist/assets/powerquery-DHf-dtTS.js +1 -0
- package/dist/assets/powershell-CFHJl5sT.js +1 -0
- package/dist/assets/powershell-DXrKVo3G.js +1 -0
- package/dist/assets/powershell-Dpen1YoG.js +1 -0
- package/dist/assets/prisma-Dd19v3D-.js +1 -0
- package/dist/assets/prisma-Dgf8IN_C.js +1 -0
- package/dist/assets/prolog-9JcfiY6I.js +1 -0
- package/dist/assets/prolog-CbFg5uaA.js +1 -0
- package/dist/assets/properties-C78fOPTZ.js +1 -0
- package/dist/assets/proto-C7zT0LnQ.js +1 -0
- package/dist/assets/proto-dqx_Sj2i.js +1 -0
- package/dist/assets/protobuf-ChK-085T.js +1 -0
- package/dist/assets/pug-CGlum2m_.js +1 -0
- package/dist/assets/pug-CIGw5rlb.js +1 -0
- package/dist/assets/pug-CoOVtr4u.js +1 -0
- package/dist/assets/puppet-BMWR74SV.js +1 -0
- package/dist/assets/puppet-DMA9R1ak.js +1 -0
- package/dist/assets/puppet-Di57VbSS.js +1 -0
- package/dist/assets/purescript-BgpM7rDH.js +1 -0
- package/dist/assets/purescript-CklMAg4u.js +1 -0
- package/dist/assets/python-B6Ihcp-E.js +1 -0
- package/dist/assets/python-B6aJPvgy.js +1 -0
- package/dist/assets/python-Cso5vRIg.js +1 -0
- package/dist/assets/q-pXgVlZs6.js +1 -0
- package/dist/assets/qml-3beO22l8.js +1 -0
- package/dist/assets/qml-DmGQGbca.js +1 -0
- package/dist/assets/qmldir-C8lEn-DE.js +1 -0
- package/dist/assets/qmldir-DXd9s5XC.js +1 -0
- package/dist/assets/qss-B7NnhfIi.js +1 -0
- package/dist/assets/qss-IeuSbFQv.js +1 -0
- package/dist/assets/r-1ts4xG1r.js +1 -0
- package/dist/assets/r-B6wPVr8A.js +1 -0
- package/dist/assets/r-Dspwwk_N.js +1 -0
- package/dist/assets/racket-BqYA7rlc.js +1 -0
- package/dist/assets/racket-CeDx7KO2.js +1 -0
- package/dist/assets/raku-BMJwmrCS.js +1 -0
- package/dist/assets/raku-DXvB9xmW.js +1 -0
- package/dist/assets/razor-BaMsy-R7.js +1 -0
- package/dist/assets/razor-Uh8Bk_45.js +1 -0
- package/dist/assets/red-DR6ZwibK.js +1 -0
- package/dist/assets/red-bN70gL4F.js +1 -0
- package/dist/assets/reg-C-SQnVFl.js +1 -0
- package/dist/assets/reg-Cvyu2Gnx.js +1 -0
- package/dist/assets/regexp-CDVJQ6XC.js +1 -0
- package/dist/assets/regexp-DK-WlHNu.js +1 -0
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +1 -0
- package/dist/assets/rel-BlqNHz8k.js +1 -0
- package/dist/assets/rel-C3B-1QV4.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-DGmmT9Wf.js +1 -0
- package/dist/assets/renderMobileApp-Dd8P9hvM.js +23 -0
- package/dist/assets/riscv-BM1_JUlF.js +1 -0
- package/dist/assets/riscv-C5dpdlr7.js +1 -0
- package/dist/assets/ron-D8l8udqQ.js +1 -0
- package/dist/assets/ron-j5NfrB3z.js +1 -0
- package/dist/assets/rose-pine-CN2F2lJl.js +1 -0
- package/dist/assets/rose-pine-dawn-0S8NEhsA.js +1 -0
- package/dist/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- package/dist/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- package/dist/assets/rose-pine-moon-DzBDT4rc.js +1 -0
- package/dist/assets/rose-pine-qdsjHGoJ.js +1 -0
- package/dist/assets/rosmsg-BJDFO7_C.js +1 -0
- package/dist/assets/rosmsg-ZkSIzPhc.js +1 -0
- package/dist/assets/rpm-CTu-6PCP.js +1 -0
- package/dist/assets/rst-BrH8l1NY.js +1 -0
- package/dist/assets/rst-C371KPjr.js +1 -0
- package/dist/assets/ruby-B2Rjki9n.js +1 -0
- package/dist/assets/ruby-DusoydGj.js +1 -0
- package/dist/assets/ruby-Dw2BHqvy.js +1 -0
- package/dist/assets/runtimeSurface-Cnqjhb6Y.js +1 -0
- package/dist/assets/rust-B1yitclQ.js +1 -0
- package/dist/assets/rust-CxuBE0J8.js +1 -0
- package/dist/assets/sas-B4kiWyti.js +1 -0
- package/dist/assets/sas-DtSHuIwI.js +1 -0
- package/dist/assets/sas-cz2c8ADy.js +1 -0
- package/dist/assets/sass-Cj5Yp3dK.js +1 -0
- package/dist/assets/sass-bfEVHPJ8.js +1 -0
- package/dist/assets/scala-C151Ov-r.js +1 -0
- package/dist/assets/scala-Dtw19ca_.js +1 -0
- package/dist/assets/scheme-C41bIUwD.js +1 -0
- package/dist/assets/scheme-C98Dy4si.js +1 -0
- package/dist/assets/scheme-OJppFQF3.js +1 -0
- package/dist/assets/scss-DxBkwadj.js +1 -0
- package/dist/assets/scss-OYdSNvt2.js +1 -0
- package/dist/assets/sdbl-CvZs3G9c.js +1 -0
- package/dist/assets/sdbl-DVxCFoDh.js +1 -0
- package/dist/assets/shaderlab-CEJedz68.js +1 -0
- package/dist/assets/shaderlab-Dg9Lc6iA.js +1 -0
- package/dist/assets/shellscript-DgJyDXW6.js +1 -0
- package/dist/assets/shellscript-Yzrsuije.js +1 -0
- package/dist/assets/shellsession-BADoaaVG.js +1 -0
- package/dist/assets/shellsession-DSlzxnCM.js +1 -0
- package/dist/assets/sieve-C3Gn_uJK.js +1 -0
- package/dist/assets/simple-mode-GW_nhZxv.js +1 -0
- package/dist/assets/slack-dark-BthQWCQV.js +1 -0
- package/dist/assets/slack-dark-CWRQmTmo.js +1 -0
- package/dist/assets/slack-ochin-DO_ok7XK.js +1 -0
- package/dist/assets/slack-ochin-DqwNpetd.js +1 -0
- package/dist/assets/smalltalk-B9IHbYt7.js +1 -0
- package/dist/assets/smalltalk-BERRCDM3.js +1 -0
- package/dist/assets/smalltalk-CnHTOXQT.js +1 -0
- package/dist/assets/snazzy-light-B5llO_gK.js +1 -0
- package/dist/assets/snazzy-light-Bw305WKR.js +1 -0
- package/dist/assets/solarized-dark-DXbdFlpD.js +1 -0
- package/dist/assets/solarized-dark-G9yBjKj_.js +1 -0
- package/dist/assets/solarized-light-DeSzljQz.js +1 -0
- package/dist/assets/solarized-light-L9t79GZl.js +1 -0
- package/dist/assets/solidity-B3CjIuAd.js +1 -0
- package/dist/assets/solidity-rGO070M0.js +1 -0
- package/dist/assets/solr-DehyRSwq.js +1 -0
- package/dist/assets/soy-BSb8SaP0.js +1 -0
- package/dist/assets/soy-Brmx7dQM.js +1 -0
- package/dist/assets/sparql-BJh4JEw-.js +1 -0
- package/dist/assets/sparql-DkYu6x3z.js +1 -0
- package/dist/assets/sparql-rVzFXLq3.js +1 -0
- package/dist/assets/splunk-BtCnVYZw.js +1 -0
- package/dist/assets/splunk-h-bNAnbk.js +1 -0
- package/dist/assets/spreadsheet-BCZA_wO0.js +1 -0
- package/dist/assets/sql-BLtJtn59.js +1 -0
- package/dist/assets/sql-BbONfI8K.js +1 -0
- package/dist/assets/sql-D0XecflT.js +1 -0
- package/dist/assets/ssh-config-CywOjoXH.js +1 -0
- package/dist/assets/ssh-config-_ykCGR6B.js +1 -0
- package/dist/assets/stata-BH5u7GGu.js +1 -0
- package/dist/assets/stata-BKjsT8qe.js +1 -0
- package/dist/assets/stex-C3f8Ysf7.js +1 -0
- package/dist/assets/stylus-B533Al4x.js +1 -0
- package/dist/assets/stylus-BEDo0Tqx.js +1 -0
- package/dist/assets/stylus-CIg617SB.js +1 -0
- package/dist/assets/surrealql-2KNEnAt7.js +1 -0
- package/dist/assets/surrealql-Bq5Q-fJD.js +1 -0
- package/dist/assets/svelte-C_ipcX3V.js +1 -0
- package/dist/assets/svelte-xaxjcSSP.js +1 -0
- package/dist/assets/swift-BzpIVaGY.js +1 -0
- package/dist/assets/swift-D82vCrfD.js +1 -0
- package/dist/assets/swift-tEesUtni.js +1 -0
- package/dist/assets/synthwave-84-CbfX1IO0.js +1 -0
- package/dist/assets/synthwave-84-DRVv-_m_.js +1 -0
- package/dist/assets/system-verilog-CnnmHF94.js +1 -0
- package/dist/assets/system-verilog-Qdm_1g2q.js +1 -0
- package/dist/assets/systemd-4A_iFExJ.js +1 -0
- package/dist/assets/systemd-DKMPPZjB.js +1 -0
- package/dist/assets/talonscript-CkByrt1z.js +1 -0
- package/dist/assets/talonscript-eRuyeYHl.js +1 -0
- package/dist/assets/tasl-B9CVvjfG.js +1 -0
- package/dist/assets/tasl-QIJgUcNo.js +1 -0
- package/dist/assets/tcl-Bq9dbVAF.js +1 -0
- package/dist/assets/tcl-DVfN8rqt.js +1 -0
- package/dist/assets/tcl-dwOrl1Do.js +1 -0
- package/dist/assets/templ-BVTOctZ7.js +1 -0
- package/dist/assets/templ-P3uqSqPl.js +1 -0
- package/dist/assets/terraform-BETggiCN.js +1 -0
- package/dist/assets/terraform-CAp711iT.js +1 -0
- package/dist/assets/tex-BHxF5DMe.js +1 -0
- package/dist/assets/tex-idrVyKtj.js +1 -0
- package/dist/assets/textile-CnDTJFAw.js +1 -0
- package/dist/assets/themeResolver-BmJyTmjJ.js +1 -0
- package/dist/assets/tiddlywiki-DO-Gjzrf.js +1 -0
- package/dist/assets/tiki-DGYXhP31.js +1 -0
- package/dist/assets/tokyo-night-BvwC8l2v.js +1 -0
- package/dist/assets/tokyo-night-hegEt444.js +1 -0
- package/dist/assets/toml-Bm5Em-hy.js +1 -0
- package/dist/assets/toml-Ms459DBw.js +1 -0
- package/dist/assets/toml-vGWfd6FD.js +1 -0
- package/dist/assets/troff-wAsdV37c.js +1 -0
- package/dist/assets/ts-tags-C-gylFal.js +1 -0
- package/dist/assets/ts-tags-zn1MmPIZ.js +1 -0
- package/dist/assets/tsv-BFtm7NXx.js +1 -0
- package/dist/assets/tsv-B_m7g4N7.js +1 -0
- package/dist/assets/tsx-COt5Ahok.js +1 -0
- package/dist/assets/tsx-D6116Ycy.js +1 -0
- package/dist/assets/ttcn-CfJYG6tj.js +1 -0
- package/dist/assets/ttcn-cfg-B9xdYoR4.js +1 -0
- package/dist/assets/turtle-B1tBg_DP.js +1 -0
- package/dist/assets/turtle-BsS91CYL.js +1 -0
- package/dist/assets/turtle-DpRhbQZY.js +1 -0
- package/dist/assets/twig-DNn4PbVi.js +1 -0
- package/dist/assets/twig-DuP5mDZ4.js +1 -0
- package/dist/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/assets/typescript-BqvgUolh.js +1 -0
- package/dist/assets/typespec-BGHnOYBU.js +1 -0
- package/dist/assets/typespec-DhK7u29_.js +1 -0
- package/dist/assets/typst-DHCkPAjA.js +1 -0
- package/dist/assets/typst-Dfd71gQj.js +1 -0
- package/dist/assets/useAppFontEffects-DXASDavv.js +7043 -0
- package/dist/assets/useInlineCommentController-KJFe98HG.js +2 -0
- package/dist/assets/useOpenInAppsStore-DHAeT9zx.js +1 -0
- package/dist/assets/useWindowTitle-BcwmWC0M.js +10 -0
- package/dist/assets/v-BH76v7br.js +1 -0
- package/dist/assets/v-BcVCzyr7.js +1 -0
- package/dist/assets/vala-CsfeWuGM.js +1 -0
- package/dist/assets/vala-z59IxkFd.js +1 -0
- package/dist/assets/vb-4AxSLKIU.js +1 -0
- package/dist/assets/vb-CmGdzxic.js +1 -0
- package/dist/assets/vb-D17OF-Vu.js +1 -0
- package/dist/assets/vbscript-BuJXcnF6.js +1 -0
- package/dist/assets/velocity-D8B20fx6.js +1 -0
- package/dist/assets/vendor-aparajita-capacitor-secure-storage-5IShChL0.js +1 -0
- package/dist/assets/vendor-aria-hidden-DvXkyWUv.js +1 -0
- package/dist/assets/vendor-base-ui-DwfTg5wD.js +1 -0
- package/dist/assets/vendor-beautiful-mermaid-r4GK_usS.js +119 -0
- package/dist/assets/vendor-capacitor-app-BrDJj9I_.js +1 -0
- package/dist/assets/vendor-capacitor-core-B4EWRrHz.js +1 -0
- package/dist/assets/vendor-capacitor-keyboard-CYccoJjr.js +1 -0
- package/dist/assets/vendor-capacitor-push-notifications-KNe1zSBF.js +1 -0
- package/dist/assets/vendor-capacitor-status-bar-B-N0EOVk.js +1 -0
- package/dist/assets/vendor-ccount-c2V3InAJ.js +1 -0
- package/dist/assets/vendor-character-entities-html4-D7taRCQ7.js +1 -0
- package/dist/assets/vendor-character-entities-legacy-BpoLgcEt.js +1 -0
- package/dist/assets/vendor-class-variance-authority-5VPnzWs2.js +1 -0
- package/dist/assets/vendor-clsx-B-dksMZM.js +1 -0
- package/dist/assets/vendor-cmdk-BCEUzcHE.js +1 -0
- package/dist/assets/vendor-codemirror-autocomplete-3xMyV7a2.js +1 -0
- package/dist/assets/vendor-codemirror-commands-Bzf12ODH.js +1 -0
- package/dist/assets/vendor-codemirror-lang-angular-0K6s6cvb.js +1 -0
- package/dist/assets/vendor-codemirror-lang-cpp-B0aL03z-.js +1 -0
- package/dist/assets/vendor-codemirror-lang-css-C4jEScp7.js +1 -0
- package/dist/assets/vendor-codemirror-lang-go-CgrfVBPr.js +7 -0
- package/dist/assets/vendor-codemirror-lang-html-C1Svvdey.js +1 -0
- package/dist/assets/vendor-codemirror-lang-java-DNPz7_Bx.js +1 -0
- package/dist/assets/vendor-codemirror-lang-javascript-CrbeaEAU.js +13 -0
- package/dist/assets/vendor-codemirror-lang-jinja-DL8BR_RG.js +1 -0
- package/dist/assets/vendor-codemirror-lang-json-BTvAjwkJ.js +1 -0
- package/dist/assets/vendor-codemirror-lang-less-CPABt6NZ.js +1 -0
- package/dist/assets/vendor-codemirror-lang-liquid-BLOLOBMg.js +1 -0
- package/dist/assets/vendor-codemirror-lang-markdown-BloD9GN-.js +1 -0
- package/dist/assets/vendor-codemirror-lang-php-FIZMTuWM.js +1 -0
- package/dist/assets/vendor-codemirror-lang-python-CR2g6ukK.js +3 -0
- package/dist/assets/vendor-codemirror-lang-rust-lsPqn4k4.js +1 -0
- package/dist/assets/vendor-codemirror-lang-sass-CtECMwzH.js +1 -0
- package/dist/assets/vendor-codemirror-lang-sql-BtmZrrZH.js +2 -0
- package/dist/assets/vendor-codemirror-lang-vue-Br6DrntY.js +1 -0
- package/dist/assets/vendor-codemirror-lang-wast-DT7IlCnb.js +1 -0
- package/dist/assets/vendor-codemirror-lang-xml-wNr-LZOf.js +1 -0
- package/dist/assets/vendor-codemirror-lang-yaml-BTKFwfc2.js +1 -0
- package/dist/assets/vendor-codemirror-language-BuDklWi8.js +3 -0
- package/dist/assets/vendor-codemirror-language-data-CFVH0pe8.js +2 -0
- package/dist/assets/vendor-codemirror-search-BzyfPoRb.js +2 -0
- package/dist/assets/vendor-codemirror-state-Wdz6Qsow.js +5 -0
- package/dist/assets/vendor-codemirror-view-pXtfpqbT.js +4 -0
- package/dist/assets/vendor-comma-separated-tokens-D8TqSuIK.js +1 -0
- package/dist/assets/vendor-crelt-C8TCjufn.js +1 -0
- package/dist/assets/vendor-detect-node-es-l0sNRNKZ.js +1 -0
- package/dist/assets/vendor-diff-C3JhlQdm.js +13 -0
- package/dist/assets/vendor-dijkstrajs-COg3n3zL.js +1 -0
- package/dist/assets/vendor-dnd-kit-accessibility-Cv52zwWT.js +1 -0
- package/dist/assets/vendor-dnd-kit-core-GipZW2tY.js +5 -0
- package/dist/assets/vendor-dnd-kit-sortable-Ct2QxIHb.js +1 -0
- package/dist/assets/vendor-dnd-kit-utilities-CrOcp0Bp.js +1 -0
- package/dist/assets/vendor-dompurify-r8glNMJW.js +2 -0
- package/dist/assets/vendor-elkjs-CRCLNXjf.js +24 -0
- package/dist/assets/vendor-entities-C3BA0AJP.js +1 -0
- package/dist/assets/vendor-fflate-C7GOKjkv.js +1 -0
- package/dist/assets/vendor-floating-ui-core-CXmKXOXc.js +1 -0
- package/dist/assets/vendor-floating-ui-dom-Bkw82kL9.js +1 -0
- package/dist/assets/vendor-floating-ui-react-dom-sVFnuruh.js +1 -0
- package/dist/assets/vendor-floating-ui-utils-BzXdqYpP.js +1 -0
- package/dist/assets/vendor-framer-motion-DJASDzNt.js +1 -0
- package/dist/assets/vendor-fuse.js-Dw8P0qyA.js +1 -0
- package/dist/assets/vendor-get-nonce-C-Z93AgS.js +1 -0
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +13 -0
- package/dist/assets/vendor-hast-util-to-html-BKBhbTDb.js +9 -0
- package/dist/assets/vendor-hast-util-whitespace-D4Wp6AEg.js +1 -0
- package/dist/assets/vendor-heic2any-L8hyfc24.js +66 -0
- package/dist/assets/vendor-html-to-image-BeoRn2gJ.js +2 -0
- package/dist/assets/vendor-html-void-elements-CkdplsNl.js +1 -0
- package/dist/assets/vendor-katex-CHD9BdDO.js +257 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/vendor-lezer-common-B3-cUCR_.js +1 -0
- package/dist/assets/vendor-lezer-cpp-BleAU9o-.js +1 -0
- package/dist/assets/vendor-lezer-css-C5xB6jxd.js +1 -0
- package/dist/assets/vendor-lezer-go-CXK1UKk8.js +1 -0
- package/dist/assets/vendor-lezer-highlight-CDMHUZ74.js +1 -0
- package/dist/assets/vendor-lezer-html-C0I1p52t.js +1 -0
- package/dist/assets/vendor-lezer-java-D_zzj-NV.js +1 -0
- package/dist/assets/vendor-lezer-javascript-D7Tj24ot.js +1 -0
- package/dist/assets/vendor-lezer-json-Iaex0UN-.js +1 -0
- package/dist/assets/vendor-lezer-lr-bhwg1HMg.js +1 -0
- package/dist/assets/vendor-lezer-markdown-C6g-d6P3.js +6 -0
- package/dist/assets/vendor-lezer-php-_DHrbsZG.js +1 -0
- package/dist/assets/vendor-lezer-python-CvuFxeZl.js +1 -0
- package/dist/assets/vendor-lezer-rust-Lqgd5SAo.js +1 -0
- package/dist/assets/vendor-lezer-sass-Dnm2lY4t.js +1 -0
- package/dist/assets/vendor-lezer-xml-hUDuAyS5.js +1 -0
- package/dist/assets/vendor-lezer-yaml-BNIl6S_O.js +1 -0
- package/dist/assets/vendor-lru_map-B-cPXHXW.js +1 -0
- package/dist/assets/vendor-marijn-find-cluster-break-DXwl3gUT.js +1 -0
- package/dist/assets/vendor-marked-i_gIkIP3.js +59 -0
- package/dist/assets/vendor-morphdom-B7dS_VPB.js +1 -0
- package/dist/assets/vendor-motion-dom-DJLgZh4Q.js +1 -0
- package/dist/assets/vendor-motion-l0sNRNKZ.js +1 -0
- package/dist/assets/vendor-motion-utils-BGh8QBgx.js +1 -0
- package/dist/assets/vendor-oniguruma-parser-C0D4_9rT.js +33 -0
- package/dist/assets/vendor-oniguruma-to-es-DLBnFDCE.js +94 -0
- package/dist/assets/vendor-pierre-theme-Bp7SdUL6.js +1 -0
- package/dist/assets/vendor-pierre-theming-C6Z4M5_u.js +1 -0
- package/dist/assets/vendor-property-information-rJCbAzvX.js +1 -0
- package/dist/assets/vendor-qrcode-COsmt6jn.js +8 -0
- package/dist/assets/vendor-radix-ui-primitive-Dc_FVRD7.js +1 -0
- package/dist/assets/vendor-radix-ui-react-compose-refs-D1GnwnP9.js +1 -0
- package/dist/assets/vendor-radix-ui-react-context-CM4yFQ2I.js +1 -0
- package/dist/assets/vendor-radix-ui-react-dialog-jX3Dld8c.js +5 -0
- package/dist/assets/vendor-radix-ui-react-dismissable-layer-M7LsH3yL.js +1 -0
- package/dist/assets/vendor-radix-ui-react-focus-guards-D5MZ0nQs.js +1 -0
- package/dist/assets/vendor-radix-ui-react-focus-scope-C2K7lNPv.js +1 -0
- package/dist/assets/vendor-radix-ui-react-id-BcxRjnbX.js +1 -0
- package/dist/assets/vendor-radix-ui-react-portal-xlJWBfDg.js +1 -0
- package/dist/assets/vendor-radix-ui-react-presence-Btn-Dp7L.js +1 -0
- package/dist/assets/vendor-radix-ui-react-primitive-CfpW6_IT.js +1 -0
- package/dist/assets/vendor-radix-ui-react-slot-BxUc3Pvz.js +1 -0
- package/dist/assets/vendor-radix-ui-react-use-callback-ref-UKEWHtoP.js +1 -0
- package/dist/assets/vendor-radix-ui-react-use-controllable-state-C_ecoh35.js +1 -0
- package/dist/assets/vendor-radix-ui-react-use-effect-event-DkJl_Rn_.js +1 -0
- package/dist/assets/vendor-radix-ui-react-use-escape-keydown-Co13lHGj.js +1 -0
- package/dist/assets/vendor-radix-ui-react-use-layout-effect-0fyGwS2_.js +1 -0
- package/dist/assets/vendor-react-DYyjh7Q0.js +9 -0
- package/dist/assets/vendor-react-drawio-Boqanm0j.js +1 -0
- package/dist/assets/vendor-react-remove-scroll-CfeMe0DL.js +4 -0
- package/dist/assets/vendor-react-remove-scroll-bar-C9YRs2nU.js +38 -0
- package/dist/assets/vendor-react-style-singleton-B1H4bWSG.js +1 -0
- package/dist/assets/vendor-regex-BBWcyTkg.js +16 -0
- package/dist/assets/vendor-regex-recursion-JU4mF-dD.js +1 -0
- package/dist/assets/vendor-regex-utilities-2BQyy0xI.js +1 -0
- package/dist/assets/vendor-remend-BWYYytN3.js +22 -0
- package/dist/assets/vendor-remixicon-react-CzPq62YO.js +1 -0
- package/dist/assets/vendor-replit-codemirror-vim-BUqe2Slv.js +39 -0
- package/dist/assets/vendor-reselect-l0sNRNKZ.js +1 -0
- package/dist/assets/vendor-scheduler-7OC5HNn7.js +1 -0
- package/dist/assets/vendor-shiki-Bbf2GEcE.js +2 -0
- package/dist/assets/vendor-shikijs-core-CGM_jqPK.js +2 -0
- package/dist/assets/vendor-shikijs-engine-javascript-Cme4830A.js +1 -0
- package/dist/assets/vendor-shikijs-engine-oniguruma-DPWuIF1G.js +1 -0
- package/dist/assets/vendor-shikijs-transformers-CZWsLLKm.js +1 -0
- package/dist/assets/vendor-shikijs-types-CPSbSeK6.js +1 -0
- package/dist/assets/vendor-shikijs-vscode-textmate-BgLrS_2D.js +3 -0
- package/dist/assets/vendor-simplewebauthn-browser-VDA66rT_.js +2 -0
- package/dist/assets/vendor-sonner-dWA7qvjr.js +1 -0
- package/dist/assets/vendor-space-separated-tokens-DD3iYX1K.js +1 -0
- package/dist/assets/vendor-stringify-entities-CsH1VEn9.js +1 -0
- package/dist/assets/vendor-style-mod-NZ5MEPvj.js +3 -0
- package/dist/assets/vendor-tailwind-merge-CPcsbTWB.js +1 -0
- package/dist/assets/vendor-tanstack-react-virtual-IrbCBGJ4.js +1 -0
- package/dist/assets/vendor-tanstack-virtual-core-Dubg10g5.js +1 -0
- package/dist/assets/vendor-tslib-De9GV7Vy.js +1 -0
- package/dist/assets/vendor-use-callback-ref-CJB0R85I.js +1 -0
- package/dist/assets/vendor-use-sidecar-C-AuTczP.js +1 -0
- package/dist/assets/vendor-use-sync-external-store-B7OScDIw.js +1 -0
- package/dist/assets/vendor-vite-runtime-DD1OZmLC.js +1 -0
- package/dist/assets/vendor-w3c-keyname-Vcq4gwWv.js +1 -0
- package/dist/assets/vendor-workbox-window-BIl4cyR9.js +2 -0
- package/dist/assets/vendor-zumer-snapdom-Btyu_Xi2.js +8 -0
- package/dist/assets/vendor-zustand-CnsBKjP5.js +6 -0
- package/dist/assets/vendor-zwitch-C2o2j-tx.js +1 -0
- package/dist/assets/verilog-BQ8w6xss.js +1 -0
- package/dist/assets/verilog-C6RDOZhf.js +1 -0
- package/dist/assets/verilog-LfQUkHDc.js +1 -0
- package/dist/assets/vesper--t9w6MWz.js +1 -0
- package/dist/assets/vesper-DU1UobuO.js +1 -0
- package/dist/assets/vhdl-CeAyd5Ju.js +1 -0
- package/dist/assets/vhdl-DJCsFYgn.js +1 -0
- package/dist/assets/vhdl-lSbBsy5d.js +1 -0
- package/dist/assets/viml-CJc9bBzg.js +1 -0
- package/dist/assets/viml-pbKHW3Ia.js +1 -0
- package/dist/assets/vitesse-black-B_BYPdvp.js +1 -0
- package/dist/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/assets/vitesse-dark-D0r3Knsf.js +1 -0
- package/dist/assets/vitesse-dark-DOSOf2Sz.js +1 -0
- package/dist/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/assets/vitesse-light-gUX51GpB.js +1 -0
- package/dist/assets/vue-D2k0BAl5.js +1 -0
- package/dist/assets/vue-DN_0RTcg.js +1 -0
- package/dist/assets/vue-html-AaS7Mt5G.js +1 -0
- package/dist/assets/vue-html-zEfNEgpj.js +1 -0
- package/dist/assets/vue-vine-CQOfvN7w.js +1 -0
- package/dist/assets/vue-vine-D9eCaTqi.js +1 -0
- package/dist/assets/vyper-CDx5xZoG.js +1 -0
- package/dist/assets/vyper-DpazWNpx.js +1 -0
- package/dist/assets/wasm-CFi-k9--.js +1 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasm-MzD3tlZU.js +1 -0
- package/dist/assets/web-components-CNPQSzpa.js +1390 -0
- package/dist/assets/webidl-ZXfAyPTL.js +1 -0
- package/dist/assets/wenyan-BV7otONQ.js +1 -0
- package/dist/assets/wenyan-CYFFG9DK.js +1 -0
- package/dist/assets/wgsl-CxjTZs-b.js +1 -0
- package/dist/assets/wgsl-Dx-B1_4e.js +1 -0
- package/dist/assets/wikitext-BhOHFoWU.js +1 -0
- package/dist/assets/wikitext-Bx2qct8O.js +1 -0
- package/dist/assets/wit-5i3qLPDT.js +1 -0
- package/dist/assets/wit-DDW3hD69.js +1 -0
- package/dist/assets/wolfram-lXgVvXCa.js +1 -0
- package/dist/assets/wolfram-pu21K997.js +1 -0
- package/dist/assets/worker-BBk7yuNN.js +153 -0
- package/dist/assets/workerFactory-tw7cIdmV.js +1 -0
- package/dist/assets/xml-BkAJuBCc.js +1 -0
- package/dist/assets/xml-sdJ4AIDG.js +1 -0
- package/dist/assets/xquery-DzFWVndE.js +1 -0
- package/dist/assets/xsl-C6UUWSil.js +1 -0
- package/dist/assets/xsl-CtQFsRM5.js +1 -0
- package/dist/assets/yacas-BJ4BC0dw.js +1 -0
- package/dist/assets/yaml-Bopit2L3.js +1 -0
- package/dist/assets/yaml-Buea-lGh.js +1 -0
- package/dist/assets/z80-Hz9HOZM7.js +1 -0
- package/dist/assets/zenscript-BIAKq-aR.js +1 -0
- package/dist/assets/zenscript-DVFEvuxE.js +1 -0
- package/dist/assets/zig-VOosw3JB.js +1 -0
- package/dist/assets/zig-bSFrHQ7n.js +1 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +13 -0
- package/dist/index.html +658 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +13 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +13 -0
- package/dist/mini-chat.html +121 -0
- package/dist/mobile.html +79 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +115 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +13 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +13 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +13 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +13 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/index.d.ts +35 -0
- package/server/index.js +206 -0
- package/server/lib/client-auth/pairing.js +308 -0
- package/server/lib/client-auth/pairing.test.js +142 -0
- package/server/lib/client-auth/remote-clients.js +280 -0
- package/server/lib/client-auth/remote-clients.test.js +158 -0
- package/server/lib/cloudflare-tunnel.js +606 -0
- package/server/lib/fs/DOCUMENTATION.md +39 -0
- package/server/lib/fs/pick-directory.js +236 -0
- package/server/lib/fs/pick-directory.test.js +134 -0
- package/server/lib/fs/routes.js +1543 -0
- package/server/lib/fs/routes.test.js +920 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +180 -0
- package/server/lib/git/credentials.js +41 -0
- package/server/lib/git/identity-storage.js +113 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/issue-2746-longpaths.test.js +206 -0
- package/server/lib/git/routes.js +1089 -0
- package/server/lib/git/routes.test.js +210 -0
- package/server/lib/git/service.js +4927 -0
- package/server/lib/git/service.test.js +1216 -0
- package/server/lib/inherited-env.js +74 -0
- package/server/lib/inherited-env.test.js +65 -0
- package/server/lib/ngrok-tunnel.js +343 -0
- package/server/lib/ngrok-tunnel.test.js +50 -0
- package/server/lib/package-manager.js +850 -0
- package/server/lib/package-manager.test.js +370 -0
- package/server/lib/path-realpath-cache.js +85 -0
- package/server/lib/path-realpath-cache.test.js +75 -0
- package/server/lib/pi/archive-store.js +52 -0
- package/server/lib/pi/archive-store.test.js +32 -0
- package/server/lib/pi/attachment-store.js +98 -0
- package/server/lib/pi/attachment-store.test.js +40 -0
- package/server/lib/pi/custom-themes.js +33 -0
- package/server/lib/pi/custom-themes.test.js +21 -0
- package/server/lib/pi/legacy-ui-model-defaults.js +57 -0
- package/server/lib/pi/legacy-ui-model-defaults.test.js +65 -0
- package/server/lib/pi/model-config-store.js +173 -0
- package/server/lib/pi/model-config-store.test.js +42 -0
- package/server/lib/pi/routes.js +956 -0
- package/server/lib/pi/routes.test.js +733 -0
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +41 -0
- package/server/lib/pi/session-daemon/daemon-process.js +113 -0
- package/server/lib/pi/session-daemon/ipc-client.js +144 -0
- package/server/lib/pi/session-daemon/runtime-registry.js +149 -0
- package/server/lib/pi/session-daemon/runtime-registry.test.js +109 -0
- package/server/lib/pi/session-daemon/session-daemon.js +1903 -0
- package/server/lib/pi/session-daemon/session-daemon.test.js +1306 -0
- package/server/lib/pi/session-daemon/session-jsonl.js +94 -0
- package/server/lib/pi/session-daemon/session-jsonl.test.js +73 -0
- package/server/lib/pi/session-daemon/supervisor.js +458 -0
- package/server/lib/pi/session-daemon/supervisor.test.js +212 -0
- package/server/lib/pi/session-folders-store.js +74 -0
- package/server/lib/pi/session-folders-store.test.js +45 -0
- package/server/lib/pi/session-lifecycle.integration.test.js +197 -0
- package/server/lib/pi/settings-store.js +133 -0
- package/server/lib/pi/settings-store.test.js +113 -0
- package/server/lib/pi/thinking-levels.js +39 -0
- package/server/lib/pi/thinking-levels.test.js +34 -0
- package/server/lib/pi/ui-settings-store.js +68 -0
- package/server/lib/pi/ui-settings-store.test.js +40 -0
- package/server/lib/pichamber-data-dir.js +78 -0
- package/server/lib/pichamber-data-dir.test.js +105 -0
- package/server/lib/pichamber-data-root-authority.test.js +81 -0
- package/server/lib/preview/proxy-runtime.js +1604 -0
- package/server/lib/preview/proxy-runtime.test.js +528 -0
- package/server/lib/pwa-keys-contract.test.ts +62 -0
- package/server/lib/relay/DOCUMENTATION.md +106 -0
- package/server/lib/relay/cross-compat.test.js +135 -0
- package/server/lib/relay/e2ee.js +341 -0
- package/server/lib/relay/e2ee.test.js +124 -0
- package/server/lib/relay/host-client.js +389 -0
- package/server/lib/relay/host-client.test.js +292 -0
- package/server/lib/relay/identity.js +93 -0
- package/server/lib/relay/identity.test.js +78 -0
- package/server/lib/relay/service.js +9 -0
- package/server/lib/relay/signing-key.js +73 -0
- package/server/lib/relay/tunnel-codec.js +373 -0
- package/server/lib/relay/tunnel-codec.test.js +58 -0
- package/server/lib/relay/tunnel-host.js +465 -0
- package/server/lib/security/bind-host.js +40 -0
- package/server/lib/security/bind-host.test.js +36 -0
- package/server/lib/security/packaged-client-origins.js +11 -0
- package/server/lib/security/request-security.js +122 -0
- package/server/lib/security/request-security.test.js +57 -0
- package/server/lib/server/cli-entry-runtime.js +44 -0
- package/server/lib/server/cli-options.js +132 -0
- package/server/lib/server/core-routes.js +1041 -0
- package/server/lib/server/cors.js +51 -0
- package/server/lib/server/cors.test.js +37 -0
- package/server/lib/server/lan-addresses.js +41 -0
- package/server/lib/server/lan-addresses.test.js +42 -0
- package/server/lib/server/path-utils.js +27 -0
- package/server/lib/server/tunnel-auth.js +586 -0
- package/server/lib/terminal/DOCUMENTATION.md +48 -0
- package/server/lib/terminal/history.js +83 -0
- package/server/lib/terminal/history.test.js +19 -0
- package/server/lib/terminal/runtime.js +391 -0
- package/server/lib/terminal/runtime.test.js +566 -0
- package/server/lib/terminal/shells.js +100 -0
- package/server/lib/terminal/shells.test.js +73 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/terminal/theme-response.js +98 -0
- package/server/lib/terminal/theme-response.test.js +70 -0
- package/server/lib/tunnels/DOCUMENTATION.md +33 -0
- package/server/lib/tunnels/executable-search.js +140 -0
- package/server/lib/tunnels/executable-search.test.js +96 -0
- package/server/lib/tunnels/install-help.js +56 -0
- package/server/lib/tunnels/install-help.test.js +31 -0
- package/server/lib/tunnels/providers/cloudflare.js +54 -0
- package/server/lib/tunnels/providers/ngrok.js +32 -0
- package/server/lib/tunnels/types.js +16 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +44 -0
- package/server/lib/ui-auth/ui-auth.js +972 -0
- package/server/lib/ui-auth/ui-auth.test.js +250 -0
- package/server/lib/ui-auth/ui-passkeys.js +543 -0
- package/server/lib/workspace/host.js +91 -0
|
@@ -0,0 +1,4927 @@
|
|
|
1
|
+
import simpleGit from 'simple-git';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
import { promisify } from 'util';
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { resolvePiChamberDataPath } from '../pichamber-data-dir.js';
|
|
9
|
+
|
|
10
|
+
const fsp = fs.promises;
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
|
14
|
+
let resolvedGitBinary = null;
|
|
15
|
+
const worktreeBootstrapState = new Map();
|
|
16
|
+
const activeWorktreeBootstrapTasks = new Map();
|
|
17
|
+
const remoteExistenceCache = new Map();
|
|
18
|
+
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i;
|
|
19
|
+
const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed';
|
|
20
|
+
const REMOTE_EXISTENCE_CACHE_TTL_MS = 30_000;
|
|
21
|
+
const gitIndexMutationQueues = new Map();
|
|
22
|
+
|
|
23
|
+
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
|
|
24
|
+
const WORKTREE_BOOTSTRAP_READY = 'ready';
|
|
25
|
+
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
|
|
26
|
+
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
|
|
27
|
+
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
|
|
28
|
+
const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready';
|
|
29
|
+
const GIT_NULL_REF = '0'.repeat(40);
|
|
30
|
+
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
|
31
|
+
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
|
32
|
+
|
|
33
|
+
const toBootstrapStateKey = (directory) => {
|
|
34
|
+
const normalized = normalizeDirectoryPath(directory);
|
|
35
|
+
if (!normalized) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
return path.resolve(normalized);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const createWorktreeBootstrapState = (status, phase, error = null) => ({
|
|
42
|
+
status,
|
|
43
|
+
phase,
|
|
44
|
+
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
|
|
45
|
+
updatedAt: Date.now(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const setWorktreeBootstrapState = (directory, status, phase, error = null) => {
|
|
49
|
+
const key = toBootstrapStateKey(directory);
|
|
50
|
+
if (!key) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const state = createWorktreeBootstrapState(status, phase, error);
|
|
54
|
+
worktreeBootstrapState.set(key, state);
|
|
55
|
+
return state;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const clearWorktreeBootstrapState = (directory) => {
|
|
59
|
+
const key = toBootstrapStateKey(directory);
|
|
60
|
+
if (!key) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
worktreeBootstrapState.delete(key);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const trackWorktreeBootstrapTask = (directory, task) => {
|
|
67
|
+
const key = toBootstrapStateKey(directory);
|
|
68
|
+
if (!key) {
|
|
69
|
+
return task;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
activeWorktreeBootstrapTasks.set(key, task);
|
|
73
|
+
const clearTask = () => {
|
|
74
|
+
if (activeWorktreeBootstrapTasks.get(key) === task) {
|
|
75
|
+
activeWorktreeBootstrapTasks.delete(key);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
void task.then(clearTask, clearTask);
|
|
79
|
+
return task;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const waitForActiveWorktreeBootstrap = async (directory) => {
|
|
83
|
+
const key = toBootstrapStateKey(directory);
|
|
84
|
+
if (!key) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
const task = activeWorktreeBootstrapTasks.get(key);
|
|
90
|
+
if (!task) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await task.catch(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const isExecutableFile = (candidate) => {
|
|
98
|
+
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const stat = fs.statSync(candidate);
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (process.platform === 'win32') {
|
|
107
|
+
const ext = path.extname(candidate).toLowerCase();
|
|
108
|
+
return ext.length === 0 || ext === '.exe' || ext === '.cmd' || ext === '.bat' || ext === '.com';
|
|
109
|
+
}
|
|
110
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const normalizeGitExecutableCandidate = (candidate) => {
|
|
118
|
+
if (typeof candidate !== 'string') {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const trimmed = candidate.trim();
|
|
122
|
+
if (!trimmed) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ext = path.extname(trimmed).toLowerCase();
|
|
127
|
+
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
|
|
128
|
+
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
|
|
129
|
+
if (isExecutableFile(exeCandidate)) {
|
|
130
|
+
return exeCandidate;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return trimmed;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const isSafeSimpleGitBinary = (candidate) => (
|
|
138
|
+
typeof candidate === 'string' && SIMPLE_GIT_SAFE_BINARY_PATTERN.test(candidate)
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const createSimpleGit = (options) => {
|
|
142
|
+
if (!options?.unsafe?.allowUnsafeCustomBinary) {
|
|
143
|
+
return simpleGit(options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const originalWarn = console.warn;
|
|
147
|
+
console.warn = (...args) => {
|
|
148
|
+
if (String(args[0] || '').includes(SIMPLE_GIT_UNSAFE_BINARY_WARNING)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
originalWarn(...args);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return simpleGit(options);
|
|
156
|
+
} finally {
|
|
157
|
+
console.warn = originalWarn;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const listPathExecutableCandidates = (binaryName) => {
|
|
162
|
+
const currentPath = process.env.PATH || '';
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const matches = [];
|
|
165
|
+
for (const segment of currentPath.split(path.delimiter)) {
|
|
166
|
+
const dir = typeof segment === 'string' ? segment.trim() : '';
|
|
167
|
+
if (!dir || seen.has(dir)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
seen.add(dir);
|
|
171
|
+
matches.push(path.join(dir, binaryName));
|
|
172
|
+
}
|
|
173
|
+
return matches;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const listWindowsGitInstallCandidates = () => {
|
|
177
|
+
const roots = [
|
|
178
|
+
process.env.ProgramFiles,
|
|
179
|
+
process.env['ProgramFiles(x86)'],
|
|
180
|
+
process.env.LocalAppData,
|
|
181
|
+
]
|
|
182
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
183
|
+
.filter(Boolean);
|
|
184
|
+
|
|
185
|
+
const candidates = [];
|
|
186
|
+
for (const root of roots) {
|
|
187
|
+
candidates.push(path.join(root, 'Git', 'cmd', 'git.exe'));
|
|
188
|
+
candidates.push(path.join(root, 'Git', 'bin', 'git.exe'));
|
|
189
|
+
candidates.push(path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'));
|
|
190
|
+
candidates.push(path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'));
|
|
191
|
+
candidates.push(path.join(root, 'Programs', 'Git', 'bin', 'git.exe'));
|
|
192
|
+
}
|
|
193
|
+
return candidates;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const resolveGitBinary = () => {
|
|
197
|
+
if (process.platform !== 'win32') {
|
|
198
|
+
return 'git';
|
|
199
|
+
}
|
|
200
|
+
if (resolvedGitBinary) {
|
|
201
|
+
return resolvedGitBinary;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const explicit = [process.env.GIT_BINARY, process.env.PICHAMBER_GIT_BINARY]
|
|
205
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
for (const candidate of explicit) {
|
|
208
|
+
const normalized = normalizeGitExecutableCandidate(candidate);
|
|
209
|
+
if (isExecutableFile(normalized)) {
|
|
210
|
+
resolvedGitBinary = normalized;
|
|
211
|
+
return resolvedGitBinary;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const pathDiscovered = [
|
|
216
|
+
...listPathExecutableCandidates('git.exe'),
|
|
217
|
+
...listPathExecutableCandidates('git'),
|
|
218
|
+
]
|
|
219
|
+
.map(normalizeGitExecutableCandidate)
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.filter((candidate) => isExecutableFile(candidate));
|
|
222
|
+
if (pathDiscovered.length > 0) {
|
|
223
|
+
resolvedGitBinary = 'git';
|
|
224
|
+
return resolvedGitBinary;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const discovered = [
|
|
228
|
+
...listWindowsGitInstallCandidates(),
|
|
229
|
+
]
|
|
230
|
+
.map(normalizeGitExecutableCandidate)
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.filter((candidate) => isExecutableFile(candidate));
|
|
233
|
+
|
|
234
|
+
const preferredExe = discovered.find((candidate) => isSafeSimpleGitBinary(candidate) && candidate.toLowerCase().endsWith('.exe'))
|
|
235
|
+
|| discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
|
|
236
|
+
resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
|
|
237
|
+
return resolvedGitBinary;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const getGitBinary = () => resolveGitBinary();
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Escape an SSH key path for use in core.sshCommand.
|
|
244
|
+
* Handles Windows/Unix differences and prevents command injection.
|
|
245
|
+
*/
|
|
246
|
+
function escapeSshKeyPath(sshKeyPath) {
|
|
247
|
+
const isWindows = process.platform === 'win32';
|
|
248
|
+
|
|
249
|
+
// Normalize path first on Windows (convert backslashes to forward slashes)
|
|
250
|
+
let normalizedPath = sshKeyPath;
|
|
251
|
+
if (isWindows) {
|
|
252
|
+
normalizedPath = sshKeyPath.replace(/\\/g, '/');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Validate: reject paths with characters that could enable injection
|
|
256
|
+
// Allow only alphanumeric, path separators, dots, dashes, underscores, spaces, and colons (for Windows drives)
|
|
257
|
+
// Note: backslash is not in this list since we've already normalized Windows paths
|
|
258
|
+
const dangerousChars = /[`$!"';&|<>(){}[\]*?#~]/;
|
|
259
|
+
if (dangerousChars.test(normalizedPath)) {
|
|
260
|
+
throw new Error(`SSH key path contains invalid characters: ${sshKeyPath}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isWindows) {
|
|
264
|
+
// On Windows, Git (via MSYS/MinGW) expects Unix-style paths
|
|
265
|
+
// Convert "C:/path" to "/c/path" for MSYS compatibility
|
|
266
|
+
let unixPath = normalizedPath;
|
|
267
|
+
const driveMatch = unixPath.match(/^([A-Za-z]):\//);
|
|
268
|
+
if (driveMatch) {
|
|
269
|
+
unixPath = `/${driveMatch[1].toLowerCase()}${unixPath.slice(2)}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Use single quotes for the path (prevents shell interpretation)
|
|
273
|
+
return `'${unixPath}'`;
|
|
274
|
+
} else {
|
|
275
|
+
// On Unix, use single quotes and escape any single quotes in the path
|
|
276
|
+
// Single quotes prevent all shell interpretation except for single quotes themselves
|
|
277
|
+
const escaped = normalizedPath.replace(/'/g, "'\\''");
|
|
278
|
+
return `'${escaped}'`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build the SSH command string for git config
|
|
284
|
+
*/
|
|
285
|
+
function buildSshCommand(sshKeyPath) {
|
|
286
|
+
const escapedPath = escapeSshKeyPath(sshKeyPath);
|
|
287
|
+
return `ssh -i ${escapedPath} -o IdentitiesOnly=yes`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const isSocketPath = async (candidate) => {
|
|
291
|
+
if (!candidate || typeof candidate !== 'string') {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const stat = await fsp.stat(candidate);
|
|
296
|
+
return typeof stat.isSocket === 'function' && stat.isSocket();
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const resolveSshAuthSock = async () => {
|
|
303
|
+
const existing = (process.env.SSH_AUTH_SOCK || '').trim();
|
|
304
|
+
if (existing) {
|
|
305
|
+
return existing;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (process.platform === 'win32') {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const gpgSock = path.join(os.homedir(), '.gnupg', 'S.gpg-agent.ssh');
|
|
313
|
+
if (await isSocketPath(gpgSock)) {
|
|
314
|
+
return gpgSock;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const runGpgconf = async (args) => {
|
|
318
|
+
for (const candidate of gpgconfCandidates) {
|
|
319
|
+
try {
|
|
320
|
+
const { stdout } = await execFileAsync(candidate, args);
|
|
321
|
+
return String(stdout || '');
|
|
322
|
+
} catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return '';
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const candidate = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
|
330
|
+
if (candidate && await isSocketPath(candidate)) {
|
|
331
|
+
return candidate;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (candidate) {
|
|
335
|
+
await runGpgconf(['--launch', 'gpg-agent']);
|
|
336
|
+
const retried = (await runGpgconf(['--list-dirs', 'agent-ssh-socket'])).trim();
|
|
337
|
+
if (retried && await isSocketPath(retried)) {
|
|
338
|
+
return retried;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return null;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const buildGitEnv = async () => {
|
|
346
|
+
const env = { ...process.env };
|
|
347
|
+
if (!env.SSH_AUTH_SOCK || !env.SSH_AUTH_SOCK.trim()) {
|
|
348
|
+
const resolved = await resolveSshAuthSock();
|
|
349
|
+
if (resolved) {
|
|
350
|
+
env.SSH_AUTH_SOCK = resolved;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return env;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const createGit = async (directory, { allowUnsafeSshCommand = false } = {}) => {
|
|
357
|
+
const env = await buildGitEnv();
|
|
358
|
+
const spawnOptions = { windowsHide: true };
|
|
359
|
+
const binary = getGitBinary();
|
|
360
|
+
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
|
|
361
|
+
const unsafe = hasCustomBinary || allowUnsafeSshCommand
|
|
362
|
+
? {
|
|
363
|
+
...(hasCustomBinary && { allowUnsafeCustomBinary: true }),
|
|
364
|
+
...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }),
|
|
365
|
+
}
|
|
366
|
+
: undefined;
|
|
367
|
+
// Always pin simple-git to an explicit working directory. Omitting baseDir
|
|
368
|
+
// makes simple-git use process.cwd(), which breaks when the PiChamber
|
|
369
|
+
// server was launched from a neutral directory (e.g. $HOME) and the opened
|
|
370
|
+
// project lives elsewhere — session/project discovery then sees spurious
|
|
371
|
+
// "not a git repository" errors and can abort enumeration.
|
|
372
|
+
const baseDir = normalizeDirectoryPath(directory);
|
|
373
|
+
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
|
374
|
+
throw new Error('Git directory is required');
|
|
375
|
+
}
|
|
376
|
+
return createSimpleGit({
|
|
377
|
+
baseDir,
|
|
378
|
+
env,
|
|
379
|
+
spawnOptions,
|
|
380
|
+
binary,
|
|
381
|
+
unsafe,
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Global config reads do not need a repository; use the home directory as a
|
|
386
|
+
// stable baseDir so we never accidentally inherit process.cwd().
|
|
387
|
+
const createGitForGlobalConfig = async () => createGit(os.homedir());
|
|
388
|
+
|
|
389
|
+
const normalizeDirectoryPath = (value) => {
|
|
390
|
+
if (typeof value !== 'string') {
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const trimmed = value.trim();
|
|
395
|
+
if (!trimmed) {
|
|
396
|
+
return trimmed;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (trimmed === '~') {
|
|
400
|
+
return os.homedir();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
404
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return trimmed;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const normalizePath = (value) => {
|
|
411
|
+
const normalized = normalizeDirectoryPath(value);
|
|
412
|
+
if (typeof normalized !== 'string') {
|
|
413
|
+
return normalized;
|
|
414
|
+
}
|
|
415
|
+
return normalized.replace(/\\/g, '/');
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const getGitIndexMutationQueueKey = (directory) => {
|
|
419
|
+
const normalized = normalizeDirectoryPath(directory);
|
|
420
|
+
if (!normalized) {
|
|
421
|
+
return '';
|
|
422
|
+
}
|
|
423
|
+
return path.resolve(normalized);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const withGitIndexMutationQueue = async (directory, task) => {
|
|
427
|
+
let key = getGitIndexMutationQueueKey(directory);
|
|
428
|
+
try {
|
|
429
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
430
|
+
if (directoryPath) {
|
|
431
|
+
const git = await createGit(directoryPath);
|
|
432
|
+
key = await resolveGitRepositoryRoot(directoryPath, git);
|
|
433
|
+
}
|
|
434
|
+
} catch {
|
|
435
|
+
// Fall back to the normalized directory key when the repo root is unavailable.
|
|
436
|
+
}
|
|
437
|
+
if (!key) {
|
|
438
|
+
return task();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const previous = gitIndexMutationQueues.get(key) || Promise.resolve();
|
|
442
|
+
const current = previous.catch(() => {}).then(task);
|
|
443
|
+
const tail = current.catch(() => {});
|
|
444
|
+
gitIndexMutationQueues.set(key, tail);
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
return await current;
|
|
448
|
+
} finally {
|
|
449
|
+
if (gitIndexMutationQueues.get(key) === tail) {
|
|
450
|
+
gitIndexMutationQueues.delete(key);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const normalizeFilePathList = (paths) => Array.from(new Set(
|
|
456
|
+
(Array.isArray(paths) ? paths : [paths])
|
|
457
|
+
.map((value) => String(value || '').trim())
|
|
458
|
+
.filter(Boolean)
|
|
459
|
+
));
|
|
460
|
+
|
|
461
|
+
const validateRepositoryFilePaths = (directoryPath, filePaths) => {
|
|
462
|
+
const repoRoot = path.resolve(directoryPath);
|
|
463
|
+
|
|
464
|
+
for (const filePath of filePaths) {
|
|
465
|
+
const absoluteTarget = path.resolve(repoRoot, filePath);
|
|
466
|
+
if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
|
|
467
|
+
throw new Error(`Path is outside repository: ${filePath}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const toGitPath = (value) => value.replace(/\\/g, '/');
|
|
473
|
+
|
|
474
|
+
const isInsideOrSameDirectory = (root, target) => {
|
|
475
|
+
const relative = path.relative(root, target);
|
|
476
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const resolveGitRepositoryRoot = async (directoryPath, git) => {
|
|
480
|
+
const topLevel = await git.raw(['rev-parse', '--show-toplevel']);
|
|
481
|
+
const normalizedTopLevel = topLevel.trim();
|
|
482
|
+
return path.isAbsolute(normalizedTopLevel)
|
|
483
|
+
? path.resolve(normalizedTopLevel)
|
|
484
|
+
: path.resolve(directoryPath, normalizedTopLevel);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const createRepositoryGitContext = async (directory) => {
|
|
488
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
489
|
+
if (typeof directoryPath !== 'string' || !directoryPath.trim()) {
|
|
490
|
+
throw new Error('Git directory is required');
|
|
491
|
+
}
|
|
492
|
+
const directoryGit = await createGit(directoryPath);
|
|
493
|
+
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
|
494
|
+
const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot);
|
|
495
|
+
return { directoryPath, directoryGit, repoRoot, git };
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Absolute repository root for a directory anywhere inside it. Callers that key
|
|
500
|
+
* persisted data by repository need this so two directories in the same
|
|
501
|
+
* repository do not address different records.
|
|
502
|
+
*/
|
|
503
|
+
|
|
504
|
+
const resolveGitInternalPath = async (repoRoot, git, gitPath) => {
|
|
505
|
+
const resolved = await git.raw(['rev-parse', '--git-path', gitPath]);
|
|
506
|
+
return path.resolve(repoRoot, resolved.trim());
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverride = null) => {
|
|
510
|
+
const repoRoot = repoRootOverride || await resolveGitRepositoryRoot(directoryPath, git);
|
|
511
|
+
const candidates = Array.from(new Set([
|
|
512
|
+
path.resolve(repoRoot, filePath),
|
|
513
|
+
path.resolve(directoryPath, filePath),
|
|
514
|
+
]));
|
|
515
|
+
|
|
516
|
+
for (const absolutePath of candidates) {
|
|
517
|
+
if (!isInsideOrSameDirectory(repoRoot, absolutePath)) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const repoPath = toGitPath(path.relative(repoRoot, absolutePath));
|
|
522
|
+
const worktreeEntry = await fsp.lstat(absolutePath).catch(() => null);
|
|
523
|
+
const isSymbolicLink = worktreeEntry?.isSymbolicLink() ?? false;
|
|
524
|
+
const existsInWorktree = worktreeEntry?.isFile() || isSymbolicLink;
|
|
525
|
+
const existsInIndex = await git.raw(['cat-file', '-e', `:${repoPath}`]).then(() => true).catch(() => false);
|
|
526
|
+
const existsInHead = await git.raw(['cat-file', '-e', `HEAD:${repoPath}`]).then(() => true).catch(() => false);
|
|
527
|
+
|
|
528
|
+
if (existsInWorktree || existsInIndex || existsInHead) {
|
|
529
|
+
return {
|
|
530
|
+
absolutePath,
|
|
531
|
+
repoPath,
|
|
532
|
+
repoRoot,
|
|
533
|
+
isSymbolicLink,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
throw new Error('Invalid file path');
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const cleanBranchName = (branch) => {
|
|
542
|
+
if (!branch) {
|
|
543
|
+
return branch;
|
|
544
|
+
}
|
|
545
|
+
if (branch.startsWith('refs/heads/')) {
|
|
546
|
+
return branch.substring('refs/heads/'.length);
|
|
547
|
+
}
|
|
548
|
+
if (branch.startsWith('heads/')) {
|
|
549
|
+
return branch.substring('heads/'.length);
|
|
550
|
+
}
|
|
551
|
+
if (branch.startsWith('refs/')) {
|
|
552
|
+
return branch.substring('refs/'.length);
|
|
553
|
+
}
|
|
554
|
+
return branch;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const OPENCODE_ADJECTIVES = [
|
|
558
|
+
'brave',
|
|
559
|
+
'calm',
|
|
560
|
+
'clever',
|
|
561
|
+
'cosmic',
|
|
562
|
+
'crisp',
|
|
563
|
+
'curious',
|
|
564
|
+
'eager',
|
|
565
|
+
'gentle',
|
|
566
|
+
'glowing',
|
|
567
|
+
'happy',
|
|
568
|
+
'hidden',
|
|
569
|
+
'jolly',
|
|
570
|
+
'kind',
|
|
571
|
+
'lucky',
|
|
572
|
+
'mighty',
|
|
573
|
+
'misty',
|
|
574
|
+
'neon',
|
|
575
|
+
'nimble',
|
|
576
|
+
'playful',
|
|
577
|
+
'proud',
|
|
578
|
+
'quick',
|
|
579
|
+
'quiet',
|
|
580
|
+
'shiny',
|
|
581
|
+
'silent',
|
|
582
|
+
'stellar',
|
|
583
|
+
'sunny',
|
|
584
|
+
'swift',
|
|
585
|
+
'tidy',
|
|
586
|
+
'witty',
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
const OPENCODE_NOUNS = [
|
|
590
|
+
'cabin',
|
|
591
|
+
'cactus',
|
|
592
|
+
'canyon',
|
|
593
|
+
'circuit',
|
|
594
|
+
'comet',
|
|
595
|
+
'eagle',
|
|
596
|
+
'engine',
|
|
597
|
+
'falcon',
|
|
598
|
+
'forest',
|
|
599
|
+
'garden',
|
|
600
|
+
'harbor',
|
|
601
|
+
'island',
|
|
602
|
+
'knight',
|
|
603
|
+
'lagoon',
|
|
604
|
+
'meadow',
|
|
605
|
+
'moon',
|
|
606
|
+
'mountain',
|
|
607
|
+
'nebula',
|
|
608
|
+
'orchid',
|
|
609
|
+
'otter',
|
|
610
|
+
'panda',
|
|
611
|
+
'pixel',
|
|
612
|
+
'planet',
|
|
613
|
+
'river',
|
|
614
|
+
'rocket',
|
|
615
|
+
'sailor',
|
|
616
|
+
'squid',
|
|
617
|
+
'star',
|
|
618
|
+
'tiger',
|
|
619
|
+
'wizard',
|
|
620
|
+
'wolf',
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
const OPENCODE_WORKTREE_ATTEMPTS = 26;
|
|
624
|
+
|
|
625
|
+
const getOpenCodeDataPath = () => {
|
|
626
|
+
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
627
|
+
return path.join(xdgDataHome, 'opencode');
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const pickRandom = (values) => values[Math.floor(Math.random() * values.length)];
|
|
631
|
+
|
|
632
|
+
const generateOpenCodeRandomName = () => `${pickRandom(OPENCODE_ADJECTIVES)}-${pickRandom(OPENCODE_NOUNS)}`;
|
|
633
|
+
|
|
634
|
+
const slugWorktreeName = (value) => {
|
|
635
|
+
return String(value || '')
|
|
636
|
+
.trim()
|
|
637
|
+
.replace(/^refs\/heads\//, '')
|
|
638
|
+
.replace(/^heads\//, '')
|
|
639
|
+
.replace(/\s+/g, '-')
|
|
640
|
+
.replace(/^\/+|\/+$/g, '')
|
|
641
|
+
.split('/').join('-')
|
|
642
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
643
|
+
.replace(/-+/g, '-')
|
|
644
|
+
.replace(/^-+/, '')
|
|
645
|
+
.replace(/-+$/, '')
|
|
646
|
+
.slice(0, 80);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const parseWorktreePorcelain = (raw) => {
|
|
650
|
+
const lines = String(raw || '').split('\n').map((line) => line.trim());
|
|
651
|
+
const entries = [];
|
|
652
|
+
let current = null;
|
|
653
|
+
|
|
654
|
+
for (const line of lines) {
|
|
655
|
+
if (!line) {
|
|
656
|
+
if (current?.worktree) {
|
|
657
|
+
entries.push(current);
|
|
658
|
+
}
|
|
659
|
+
current = null;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (line.startsWith('worktree ')) {
|
|
664
|
+
if (current?.worktree) {
|
|
665
|
+
entries.push(current);
|
|
666
|
+
}
|
|
667
|
+
current = { worktree: line.substring('worktree '.length).trim() };
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!current) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (line.startsWith('HEAD ')) {
|
|
676
|
+
current.head = line.substring('HEAD '.length).trim();
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (line.startsWith('branch ')) {
|
|
681
|
+
const branchRef = line.substring('branch '.length).trim();
|
|
682
|
+
current.branchRef = branchRef;
|
|
683
|
+
current.branch = cleanBranchName(branchRef);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (current?.worktree) {
|
|
688
|
+
entries.push(current);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return entries;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const canonicalPath = async (input) => {
|
|
695
|
+
const absolutePath = path.resolve(input);
|
|
696
|
+
const realPath = await fsp.realpath(absolutePath).catch(() => absolutePath);
|
|
697
|
+
const normalized = path.normalize(realPath);
|
|
698
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const checkPathExists = async (targetPath) => {
|
|
702
|
+
try {
|
|
703
|
+
await fsp.stat(targetPath);
|
|
704
|
+
return true;
|
|
705
|
+
} catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const normalizeStartRef = (value) => {
|
|
711
|
+
const trimmed = String(value || '').trim();
|
|
712
|
+
if (!trimmed) {
|
|
713
|
+
return 'HEAD';
|
|
714
|
+
}
|
|
715
|
+
return trimmed;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
function isValidCommitHash(hash) {
|
|
719
|
+
return typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const parseRemoteBranchRef = (value) => {
|
|
723
|
+
const trimmed = String(value || '').trim();
|
|
724
|
+
if (!trimmed) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (trimmed.startsWith('refs/remotes/')) {
|
|
729
|
+
const rest = trimmed.substring('refs/remotes/'.length);
|
|
730
|
+
const slashIndex = rest.indexOf('/');
|
|
731
|
+
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
remote: rest.slice(0, slashIndex),
|
|
736
|
+
branch: rest.slice(slashIndex + 1),
|
|
737
|
+
remoteRef: rest,
|
|
738
|
+
fullRef: `refs/remotes/${rest}`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (trimmed.startsWith('remotes/')) {
|
|
743
|
+
return parseRemoteBranchRef(`refs/${trimmed}`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const slashIndex = trimmed.indexOf('/');
|
|
747
|
+
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
remote: trimmed.slice(0, slashIndex),
|
|
753
|
+
branch: trimmed.slice(slashIndex + 1),
|
|
754
|
+
remoteRef: trimmed,
|
|
755
|
+
fullRef: `refs/remotes/${trimmed}`,
|
|
756
|
+
};
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const resolveRemoteBranchRef = async (primaryWorktree, value) => {
|
|
760
|
+
const raw = String(value || '').trim();
|
|
761
|
+
const parsed = parseRemoteBranchRef(raw);
|
|
762
|
+
if (!parsed) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (raw.startsWith('refs/remotes/') || raw.startsWith('remotes/')) {
|
|
767
|
+
return parsed;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const localRef = `refs/heads/${raw}`;
|
|
771
|
+
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
|
|
772
|
+
if (localExists.success) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return parsed;
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const normalizeUpstreamTarget = (remote, branch) => {
|
|
780
|
+
const remoteName = String(remote || '').trim();
|
|
781
|
+
const branchName = String(branch || '').trim();
|
|
782
|
+
if (!remoteName || !branchName) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
remote: remoteName,
|
|
787
|
+
branch: branchName,
|
|
788
|
+
full: `${remoteName}/${branchName}`,
|
|
789
|
+
};
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const parseGitErrorText = (error) => {
|
|
793
|
+
const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
|
|
794
|
+
const stdout = typeof error?.stdout === 'string' ? error.stdout : '';
|
|
795
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
796
|
+
// Some runtimes (notably Bun + simple-git GitError) surface the fatal text
|
|
797
|
+
// primarily via message/toString; keep String(error) as a last resort so
|
|
798
|
+
// "not a git repository" matching never misses and aborts callers.
|
|
799
|
+
const fallback = !message && error != null ? String(error) : '';
|
|
800
|
+
return [stderr, stdout, message, fallback]
|
|
801
|
+
.map((chunk) => String(chunk || '').trim())
|
|
802
|
+
.filter(Boolean)
|
|
803
|
+
.join('\n')
|
|
804
|
+
.trim();
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const parseAheadBehindCounts = (value) => {
|
|
808
|
+
const [aheadRaw, behindRaw] = String(value || '').trim().split(/\s+/);
|
|
809
|
+
const ahead = parseInt(aheadRaw, 10);
|
|
810
|
+
const behind = parseInt(behindRaw, 10);
|
|
811
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
return { ahead, behind };
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const getRemoteExistenceCacheKey = (directory, remoteName) => {
|
|
818
|
+
const normalizedDirectory = normalizeDirectoryPath(directory) || '';
|
|
819
|
+
return `${path.resolve(normalizedDirectory)}\0${remoteName}`;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const hasRemote = async (git, directory, remoteName) => {
|
|
823
|
+
const remote = String(remoteName || '').trim();
|
|
824
|
+
if (!remote) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const key = getRemoteExistenceCacheKey(directory, remote);
|
|
829
|
+
const cached = remoteExistenceCache.get(key);
|
|
830
|
+
if (cached && Date.now() - cached.checkedAt < REMOTE_EXISTENCE_CACHE_TTL_MS) {
|
|
831
|
+
return cached.exists;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const exists = await git
|
|
835
|
+
.raw(['remote', 'get-url', remote])
|
|
836
|
+
.then((value) => String(value || '').trim().length > 0)
|
|
837
|
+
.catch(() => false);
|
|
838
|
+
|
|
839
|
+
remoteExistenceCache.set(key, { exists, checkedAt: Date.now() });
|
|
840
|
+
return exists;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const buildRawGitOptions = (raw) => {
|
|
844
|
+
if (Array.isArray(raw)) {
|
|
845
|
+
return raw.map((value) => String(value || '').trim()).filter(Boolean);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!raw || typeof raw !== 'object') {
|
|
849
|
+
return [];
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return Object.entries(raw).flatMap(([key, value]) => {
|
|
853
|
+
const option = String(key || '').trim();
|
|
854
|
+
if (!option || value === false) {
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
if (value === true || value == null) {
|
|
858
|
+
return [option];
|
|
859
|
+
}
|
|
860
|
+
return [option, String(value)];
|
|
861
|
+
});
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const getRemoteBranchComparison = async (git, remoteName, branchName) => {
|
|
865
|
+
const remote = String(remoteName || '').trim();
|
|
866
|
+
const branch = String(branchName || '').trim();
|
|
867
|
+
if (!remote || !branch) {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const remoteRef = `refs/remotes/${remote}/${branch}`;
|
|
872
|
+
const exists = await git
|
|
873
|
+
.raw(['rev-parse', '--verify', remoteRef])
|
|
874
|
+
.then((value) => String(value || '').trim())
|
|
875
|
+
.catch(() => '');
|
|
876
|
+
if (!exists) {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const countsRaw = await git
|
|
881
|
+
.raw(['rev-list', '--left-right', '--count', `HEAD...${remoteRef}`])
|
|
882
|
+
.then((value) => String(value || '').trim())
|
|
883
|
+
.catch(() => '');
|
|
884
|
+
const counts = parseAheadBehindCounts(countsRaw);
|
|
885
|
+
if (!counts) {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return {
|
|
890
|
+
remote,
|
|
891
|
+
branch,
|
|
892
|
+
ahead: counts.ahead,
|
|
893
|
+
behind: counts.behind,
|
|
894
|
+
};
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const isNotGitRepositoryError = (error) => {
|
|
898
|
+
const text = parseGitErrorText(error);
|
|
899
|
+
return /not a git repository/i.test(text);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// A directory that no longer exists (e.g. a worktree deleted while something
|
|
903
|
+
// was still polling its status) is an expected, benign condition — not a fault
|
|
904
|
+
// to scream about. simple-git throws "Cannot use simple-git on a directory that
|
|
905
|
+
// does not exist"; the underlying fs errors are ENOENT/ENOTDIR.
|
|
906
|
+
const isMissingDirectoryError = (error) => {
|
|
907
|
+
const code = error?.code;
|
|
908
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
const text = parseGitErrorText(error);
|
|
912
|
+
return /directory that does not exist|does not exist|no such file or directory/i.test(text);
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const runGitCommand = async (cwd, args) => {
|
|
916
|
+
try {
|
|
917
|
+
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
|
|
918
|
+
cwd,
|
|
919
|
+
env: await buildGitEnv(),
|
|
920
|
+
windowsHide: true,
|
|
921
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
success: true,
|
|
925
|
+
exitCode: 0,
|
|
926
|
+
stdout: String(stdout || ''),
|
|
927
|
+
stderr: String(stderr || ''),
|
|
928
|
+
};
|
|
929
|
+
} catch (error) {
|
|
930
|
+
return {
|
|
931
|
+
success: false,
|
|
932
|
+
exitCode: typeof error?.code === 'number' ? error.code : 1,
|
|
933
|
+
stdout: String(error?.stdout || ''),
|
|
934
|
+
stderr: String(error?.stderr || ''),
|
|
935
|
+
message: parseGitErrorText(error),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const resolveGitCommitFilePath = async (repoRoot, hash, candidates) => {
|
|
941
|
+
for (const candidate of candidates) {
|
|
942
|
+
const [originalTreeResult, modifiedTreeResult] = await Promise.all([
|
|
943
|
+
runGitCommand(repoRoot, ['ls-tree', '--name-only', `${hash}^`, '--', candidate]),
|
|
944
|
+
runGitCommand(repoRoot, ['ls-tree', '--name-only', hash, '--', candidate]),
|
|
945
|
+
]);
|
|
946
|
+
|
|
947
|
+
if ((originalTreeResult.success && originalTreeResult.stdout.trim()) || (modifiedTreeResult.success && modifiedTreeResult.stdout.trim())) {
|
|
948
|
+
return candidate;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
throw new Error('Invalid file path');
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const runGitCommandOrThrow = async (cwd, args, fallbackMessage) => {
|
|
956
|
+
const result = await runGitCommand(cwd, args);
|
|
957
|
+
if (!result.success) {
|
|
958
|
+
throw new Error(result.message || fallbackMessage || 'Git command failed');
|
|
959
|
+
}
|
|
960
|
+
return result;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
964
|
+
|
|
965
|
+
const isIndexLockError = (result) => {
|
|
966
|
+
const message = [result?.message, result?.stderr, result?.stdout].filter(Boolean).join('\n');
|
|
967
|
+
return /index\.lock['"]?: File exists|another git process seems to be running/i.test(message);
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const getWorktreeIndexLockPath = async (directory) => {
|
|
971
|
+
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'index.lock']);
|
|
972
|
+
if (!result.success) {
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
const value = String(result.stdout || '').trim();
|
|
976
|
+
return value ? (path.isAbsolute(value) ? value : path.resolve(directory, value)) : null;
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const getFileIdentity = async (filePath) => {
|
|
980
|
+
try {
|
|
981
|
+
const stat = await fsp.stat(filePath);
|
|
982
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (error?.code === 'ENOENT') {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
// PiChamber places managed worktrees under a deep data-dir path
|
|
992
|
+
// (`<XDG_DATA_HOME>/opencode/worktree/<40-char project id>/<name>/`). On
|
|
993
|
+
// Windows that prefix plus a deeply nested repo file routinely exceeds
|
|
994
|
+
// MAX_PATH (260). Git can check those paths out when core.longpaths is
|
|
995
|
+
// enabled; without it, `git reset --hard` during bootstrap fails with
|
|
996
|
+
// "Filename too long" and leaves a half-populated worktree (issue #2746).
|
|
997
|
+
const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'];
|
|
998
|
+
|
|
999
|
+
const isFilenameTooLongError = (message) => /file ?name too long/i.test(String(message || ''));
|
|
1000
|
+
|
|
1001
|
+
const formatWorktreePopulateError = (message) => {
|
|
1002
|
+
const text = String(message || '').trim() || 'Failed to populate worktree';
|
|
1003
|
+
if (!isFilenameTooLongError(text)) {
|
|
1004
|
+
return text;
|
|
1005
|
+
}
|
|
1006
|
+
return [
|
|
1007
|
+
text,
|
|
1008
|
+
'The worktree checkout path exceeds this system\'s path-length limit.',
|
|
1009
|
+
'PiChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.',
|
|
1010
|
+
].join('\n');
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
export const ensureWorktreeLongpaths = async (directory) => {
|
|
1014
|
+
const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']);
|
|
1015
|
+
if (String(current.stdout || '').trim().toLowerCase() === 'true') {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
// Local config is shared across linked worktrees via the common git dir, so
|
|
1019
|
+
// subsequent PiChamber and CLI git operations in this repo also get long
|
|
1020
|
+
// path support. Failures here are non-fatal: populate still passes
|
|
1021
|
+
// `-c core.longpaths=true` on reset.
|
|
1022
|
+
await runGitCommand(directory, ['config', 'core.longpaths', 'true']);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
export const populateWorktreeWithLockRecovery = async (directory) => {
|
|
1026
|
+
await ensureWorktreeLongpaths(directory);
|
|
1027
|
+
|
|
1028
|
+
let result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
|
|
1029
|
+
if (result.success) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (!isIndexLockError(result)) {
|
|
1033
|
+
throw new Error(formatWorktreePopulateError(result.message));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS);
|
|
1037
|
+
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
|
|
1038
|
+
if (result.success) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (!isIndexLockError(result)) {
|
|
1042
|
+
throw new Error(formatWorktreePopulateError(result.message));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const lockPath = await getWorktreeIndexLockPath(directory);
|
|
1046
|
+
const identity = lockPath ? await getFileIdentity(lockPath) : null;
|
|
1047
|
+
await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS);
|
|
1048
|
+
|
|
1049
|
+
result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
|
|
1050
|
+
if (result.success) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) {
|
|
1054
|
+
throw new Error(formatWorktreePopulateError(result.message));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
await fsp.unlink(lockPath).catch((error) => {
|
|
1058
|
+
if (error?.code !== 'ENOENT') {
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
const finalResult = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS);
|
|
1063
|
+
if (!finalResult.success) {
|
|
1064
|
+
throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree'));
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// Worktrees are created with `git worktree add --no-checkout` and populated
|
|
1069
|
+
// with `git reset --hard`, neither of which runs git's post-checkout hook —
|
|
1070
|
+
// git only runs it for checkouts, clone, and worktree add *without*
|
|
1071
|
+
// --no-checkout. Invoke the hook explicitly after population to restore git's
|
|
1072
|
+
// checkout semantics: git passes the previous HEAD (null ref for a brand-new
|
|
1073
|
+
// worktree), the new HEAD, and flag 1 for a branch checkout, and runs the hook
|
|
1074
|
+
// from the worktree top-level.
|
|
1075
|
+
const runPostCheckoutHook = async (directory) => {
|
|
1076
|
+
let hookDirectory = null;
|
|
1077
|
+
try {
|
|
1078
|
+
const result = await runGitCommand(directory, ['rev-parse', '--git-path', 'hooks']);
|
|
1079
|
+
if (!result.success) return;
|
|
1080
|
+
hookDirectory = normalizeDirectoryPath(String(result.stdout || '').trim());
|
|
1081
|
+
} catch {
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (!hookDirectory) return;
|
|
1085
|
+
|
|
1086
|
+
const hookPath = path.join(hookDirectory, 'post-checkout');
|
|
1087
|
+
try {
|
|
1088
|
+
const stat = await fsp.stat(hookPath);
|
|
1089
|
+
if (!stat.isFile()) return;
|
|
1090
|
+
if (process.platform !== 'win32') {
|
|
1091
|
+
await fsp.access(hookPath, fs.constants.X_OK);
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
// Missing or non-executable hooks are skipped, matching git.
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const [headResult, gitDirResult] = await Promise.all([
|
|
1099
|
+
runGitCommand(directory, ['rev-parse', 'HEAD']),
|
|
1100
|
+
runGitCommand(directory, ['rev-parse', '--absolute-git-dir']),
|
|
1101
|
+
]);
|
|
1102
|
+
if (!headResult.success || !gitDirResult.success) return;
|
|
1103
|
+
const head = String(headResult.stdout || '').trim();
|
|
1104
|
+
const gitDir = String(gitDirResult.stdout || '').trim();
|
|
1105
|
+
if (!head || !gitDir) return;
|
|
1106
|
+
|
|
1107
|
+
try {
|
|
1108
|
+
await execFileAsync(hookPath, [GIT_NULL_REF, head, '1'], {
|
|
1109
|
+
cwd: directory,
|
|
1110
|
+
env: {
|
|
1111
|
+
...(await buildGitEnv()),
|
|
1112
|
+
GIT_DIR: gitDir,
|
|
1113
|
+
GIT_WORK_TREE: path.resolve(directory),
|
|
1114
|
+
},
|
|
1115
|
+
windowsHide: true,
|
|
1116
|
+
});
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
// A failing hook must not fail worktree creation or session bootstrap:
|
|
1119
|
+
// warn and continue.
|
|
1120
|
+
console.warn(`[GitService] post-checkout hook failed in worktree ${directory}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const derivePrimaryWorktreeRootFromGitDir = (gitDir) => {
|
|
1125
|
+
const normalized = normalizePath(gitDir);
|
|
1126
|
+
if (!normalized) return null;
|
|
1127
|
+
if (normalized.endsWith('/.git')) {
|
|
1128
|
+
return normalized.slice(0, -'/.git'.length) || null;
|
|
1129
|
+
}
|
|
1130
|
+
const marker = '/.git/worktrees/';
|
|
1131
|
+
const markerIndex = normalized.indexOf(marker);
|
|
1132
|
+
if (markerIndex > 0) {
|
|
1133
|
+
return normalized.slice(0, markerIndex) || null;
|
|
1134
|
+
}
|
|
1135
|
+
return null;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
export async function resolvePrimaryWorktreeRoot(directory) {
|
|
1139
|
+
const result = await runGitCommand(directory, ['rev-parse', '--absolute-git-dir', '--git-common-dir']);
|
|
1140
|
+
if (!result.success) {
|
|
1141
|
+
return { root: directory };
|
|
1142
|
+
}
|
|
1143
|
+
const lines = String(result.stdout || '')
|
|
1144
|
+
.split('\n')
|
|
1145
|
+
.map((line) => line.trim())
|
|
1146
|
+
.filter(Boolean);
|
|
1147
|
+
const absoluteGitDir = normalizePath(lines[0] || '');
|
|
1148
|
+
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
|
1149
|
+
if (rootFromAbsoluteGitDir) {
|
|
1150
|
+
return { root: rootFromAbsoluteGitDir };
|
|
1151
|
+
}
|
|
1152
|
+
const rawCommonDir = normalizePath(lines[1] || '');
|
|
1153
|
+
if (rawCommonDir) {
|
|
1154
|
+
const commonDir = path.isAbsolute(rawCommonDir)
|
|
1155
|
+
? rawCommonDir
|
|
1156
|
+
: path.resolve(directory, rawCommonDir);
|
|
1157
|
+
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
|
1158
|
+
if (rootFromCommonDir) {
|
|
1159
|
+
return { root: rootFromCommonDir };
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return { root: directory };
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export async function resolveWorktreeTopLevel(directory) {
|
|
1166
|
+
const result = await runGitCommand(directory, ['rev-parse', '--show-toplevel']);
|
|
1167
|
+
if (!result.success) {
|
|
1168
|
+
return { root: directory };
|
|
1169
|
+
}
|
|
1170
|
+
const root = normalizePath(String(result.stdout || '').trim());
|
|
1171
|
+
return { root: root || directory };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export async function getCommitSummaries(directory, shas) {
|
|
1175
|
+
const commits = Array.isArray(shas)
|
|
1176
|
+
? shas.map((sha) => String(sha || '').trim()).filter(Boolean)
|
|
1177
|
+
: [];
|
|
1178
|
+
if (commits.length === 0) {
|
|
1179
|
+
return { commits: [] };
|
|
1180
|
+
}
|
|
1181
|
+
if (commits.some((sha) => !/^[0-9a-fA-F]{4,64}$/.test(sha))) {
|
|
1182
|
+
throw new Error('Invalid commit SHA');
|
|
1183
|
+
}
|
|
1184
|
+
const result = await runGitCommandOrThrow(
|
|
1185
|
+
directory,
|
|
1186
|
+
['show', '-s', '--format=%H%x09%h%x09%s', ...commits, '--'],
|
|
1187
|
+
'Failed to get commit summaries'
|
|
1188
|
+
);
|
|
1189
|
+
const parsed = String(result.stdout || '')
|
|
1190
|
+
.split(/\r?\n/)
|
|
1191
|
+
.filter(Boolean)
|
|
1192
|
+
.map((line) => {
|
|
1193
|
+
const [sha, short, subject] = line.split('\t');
|
|
1194
|
+
return { sha: sha || '', short: short || '', subject: subject || '' };
|
|
1195
|
+
})
|
|
1196
|
+
.filter((entry) => entry.sha && entry.short);
|
|
1197
|
+
return { commits: parsed };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const trimGitLines = (value) => String(value || '')
|
|
1201
|
+
.split(/\r?\n/)
|
|
1202
|
+
.map((line) => line.trim())
|
|
1203
|
+
.filter(Boolean);
|
|
1204
|
+
|
|
1205
|
+
const gitStdoutText = (result) => String(result?.stdout || '').trim();
|
|
1206
|
+
const gitStderrText = (result) => String(result?.stderr || result?.message || '').trim();
|
|
1207
|
+
|
|
1208
|
+
const normalizeIntegrateBranch = (value, fieldName) => {
|
|
1209
|
+
const branch = String(value || '').trim();
|
|
1210
|
+
if (!branch) {
|
|
1211
|
+
throw new Error(`${fieldName} is required`);
|
|
1212
|
+
}
|
|
1213
|
+
if (branch.startsWith('-') || branch.includes('\0')) {
|
|
1214
|
+
throw new Error(`Invalid ${fieldName}`);
|
|
1215
|
+
}
|
|
1216
|
+
return branch;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const normalizeIntegrateSha = (value) => {
|
|
1220
|
+
const sha = String(value || '').trim();
|
|
1221
|
+
if (!/^[0-9a-fA-F]{4,64}$/.test(sha)) {
|
|
1222
|
+
throw new Error('Invalid commit SHA');
|
|
1223
|
+
}
|
|
1224
|
+
return sha;
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
const normalizeIntegratePath = (value, fieldName) => {
|
|
1228
|
+
const target = normalizeDirectoryPath(value);
|
|
1229
|
+
if (!target) {
|
|
1230
|
+
throw new Error(`${fieldName} is required`);
|
|
1231
|
+
}
|
|
1232
|
+
return path.resolve(target);
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
const runGitOk = (result) => Boolean(result?.success);
|
|
1236
|
+
|
|
1237
|
+
const listGitWorktreesForIntegrate = async (repoRoot) => {
|
|
1238
|
+
const out = await runGitCommandOrThrow(repoRoot, ['worktree', 'list', '--porcelain'], 'Failed to list git worktrees');
|
|
1239
|
+
const entries = [];
|
|
1240
|
+
let current = null;
|
|
1241
|
+
for (const line of String(out.stdout || '').split(/\r?\n/)) {
|
|
1242
|
+
if (line.startsWith('worktree ')) {
|
|
1243
|
+
if (current) entries.push(current);
|
|
1244
|
+
current = { path: line.slice('worktree '.length).trim(), branchRef: null };
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
if (!current) continue;
|
|
1248
|
+
if (line.startsWith('branch ')) {
|
|
1249
|
+
current.branchRef = line.slice('branch '.length).trim();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (current) entries.push(current);
|
|
1253
|
+
return entries.filter((entry) => Boolean(entry.path));
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
const ensureLocalIntegrateBranch = async (repoRoot, candidate) => {
|
|
1257
|
+
const raw = normalizeIntegrateBranch(candidate, 'targetBranch');
|
|
1258
|
+
if (raw === 'HEAD') {
|
|
1259
|
+
return 'HEAD';
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const hasLocal = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${raw}`]);
|
|
1263
|
+
if (runGitOk(hasLocal)) {
|
|
1264
|
+
return raw;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (raw.startsWith('remotes/')) {
|
|
1268
|
+
const remoteRef = raw.slice('remotes/'.length);
|
|
1269
|
+
const parts = remoteRef.split('/');
|
|
1270
|
+
const remote = normalizeIntegrateBranch(parts[0] || 'origin', 'remote');
|
|
1271
|
+
const name = normalizeIntegrateBranch(parts.slice(1).join('/'), 'branch');
|
|
1272
|
+
await runGitCommandOrThrow(repoRoot, ['branch', '--track', name, `${remote}/${name}`], 'Failed to track remote branch');
|
|
1273
|
+
return name;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const remoteCheck = await runGitCommand(repoRoot, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${raw}`]);
|
|
1277
|
+
if (runGitOk(remoteCheck)) {
|
|
1278
|
+
await runGitCommandOrThrow(repoRoot, ['branch', '--track', raw, `origin/${raw}`], 'Failed to track remote branch');
|
|
1279
|
+
return raw;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return raw;
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
export async function computeIntegratePlan(input = {}) {
|
|
1286
|
+
const repoRoot = normalizeIntegratePath(input.repoRoot, 'repoRoot');
|
|
1287
|
+
const sourceBranch = normalizeIntegrateBranch(input.sourceBranch, 'sourceBranch');
|
|
1288
|
+
const targetBranchRaw = normalizeIntegrateBranch(input.targetBranch, 'targetBranch');
|
|
1289
|
+
if (sourceBranch === 'HEAD' || targetBranchRaw === 'HEAD') {
|
|
1290
|
+
return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const targetBranch = await ensureLocalIntegrateBranch(repoRoot, targetBranchRaw);
|
|
1294
|
+
const cherry = await runGitCommandOrThrow(repoRoot, ['cherry', targetBranch, sourceBranch], 'Failed to compute cherry commits');
|
|
1295
|
+
const plus = new Set();
|
|
1296
|
+
for (const line of trimGitLines(cherry.stdout)) {
|
|
1297
|
+
const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i);
|
|
1298
|
+
if (match) {
|
|
1299
|
+
plus.add(match[1]);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const revList = await runGitCommandOrThrow(repoRoot, ['rev-list', '--reverse', `${targetBranch}..${sourceBranch}`], 'Failed to list commits');
|
|
1304
|
+
const commits = trimGitLines(revList.stdout).filter((sha) => plus.has(sha));
|
|
1305
|
+
return { repoRoot, sourceBranch, targetBranch, commits };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const createIntegrateTempWorktree = async (repoRoot, targetBranch) => {
|
|
1309
|
+
const tmpParent = resolvePiChamberDataPath(['tmp']);
|
|
1310
|
+
await fsp.mkdir(tmpParent, { recursive: true });
|
|
1311
|
+
const tmpDir = await fsp.mkdtemp(path.join(tmpParent, 'oc-integrate-'));
|
|
1312
|
+
try {
|
|
1313
|
+
await runGitCommandOrThrow(repoRoot, ['worktree', 'add', '--force', tmpDir, targetBranch], 'Failed to create temp worktree');
|
|
1314
|
+
return tmpDir;
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1317
|
+
throw error;
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const removeIntegrateTempWorktree = async (repoRoot, tmpDir) => {
|
|
1322
|
+
await runGitCommand(repoRoot, ['worktree', 'remove', '--force', tmpDir]).catch(() => undefined);
|
|
1323
|
+
await runGitCommand(repoRoot, ['worktree', 'prune']).catch(() => undefined);
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
const maybeFastForwardIntegrateUpstream = async (tmpDir) => {
|
|
1327
|
+
const upstream = await runGitCommand(tmpDir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
1328
|
+
const upstreamRef = gitStdoutText(upstream);
|
|
1329
|
+
if (!upstreamRef) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
await runGitCommand(tmpDir, ['fetch']);
|
|
1333
|
+
const ff = await runGitCommand(tmpDir, ['merge', '--ff-only', upstreamRef]);
|
|
1334
|
+
if (!runGitOk(ff)) {
|
|
1335
|
+
throw new Error(gitStderrText(ff) || 'Fast-forward failed');
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
export async function getIntegrateConflictDetails(tmpDir) {
|
|
1340
|
+
const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath');
|
|
1341
|
+
const [status, unmerged, diff, meta, patch] = await Promise.all([
|
|
1342
|
+
runGitCommand(target, ['status', '--porcelain']),
|
|
1343
|
+
runGitCommand(target, ['diff', '--name-only', '--diff-filter=U']),
|
|
1344
|
+
runGitCommand(target, ['diff']),
|
|
1345
|
+
runGitCommand(target, ['show', '--no-patch', '--pretty=fuller', 'CHERRY_PICK_HEAD']),
|
|
1346
|
+
runGitCommand(target, ['show', 'CHERRY_PICK_HEAD']),
|
|
1347
|
+
]);
|
|
1348
|
+
|
|
1349
|
+
return {
|
|
1350
|
+
statusPorcelain: String(status.stdout || ''),
|
|
1351
|
+
unmergedFiles: trimGitLines(unmerged.stdout),
|
|
1352
|
+
diff: String(diff.stdout || diff.stderr || ''),
|
|
1353
|
+
currentPatchMeta: String(meta.stdout || meta.stderr || ''),
|
|
1354
|
+
currentPatch: String(patch.stdout || patch.stderr || ''),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export async function isCherryPickInProgress(tmpDir) {
|
|
1359
|
+
const target = normalizeIntegratePath(tmpDir, 'tempWorktreePath');
|
|
1360
|
+
const head = await runGitCommand(target, ['rev-parse', '--verify', '--quiet', 'CHERRY_PICK_HEAD']);
|
|
1361
|
+
return { inProgress: runGitOk(head) };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const computeCleanIntegrateWorktreesToSync = async ({ repoRoot, targetBranch, excludePaths }) => {
|
|
1365
|
+
const targetRef = `refs/heads/${targetBranch}`;
|
|
1366
|
+
const exclude = new Set(excludePaths);
|
|
1367
|
+
const entries = await listGitWorktreesForIntegrate(repoRoot);
|
|
1368
|
+
const candidates = entries
|
|
1369
|
+
.filter((entry) => entry.branchRef === targetRef)
|
|
1370
|
+
.map((entry) => entry.path)
|
|
1371
|
+
.filter((candidate) => candidate && !exclude.has(candidate));
|
|
1372
|
+
|
|
1373
|
+
const clean = [];
|
|
1374
|
+
for (const candidate of candidates) {
|
|
1375
|
+
const status = await runGitCommand(candidate, ['status', '--porcelain']);
|
|
1376
|
+
if (!gitStdoutText(status)) {
|
|
1377
|
+
clean.push(candidate);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return clean;
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const syncCleanIntegrateTargetWorktrees = async (paths) => {
|
|
1384
|
+
for (const target of paths) {
|
|
1385
|
+
await runGitCommand(target, ['reset', '--hard']).catch(() => undefined);
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
const normalizeIntegratePlan = async (plan = {}) => {
|
|
1390
|
+
const repoRoot = normalizeIntegratePath(plan.repoRoot, 'repoRoot');
|
|
1391
|
+
const sourceBranch = normalizeIntegrateBranch(plan.sourceBranch, 'sourceBranch');
|
|
1392
|
+
const targetBranch = normalizeIntegrateBranch(plan.targetBranch, 'targetBranch');
|
|
1393
|
+
const commits = Array.isArray(plan.commits) ? plan.commits.map(normalizeIntegrateSha) : [];
|
|
1394
|
+
return { repoRoot, sourceBranch, targetBranch, commits };
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
const normalizeIntegrateState = (state = {}) => ({
|
|
1398
|
+
repoRoot: normalizeIntegratePath(state.repoRoot, 'repoRoot'),
|
|
1399
|
+
tempWorktreePath: normalizeIntegratePath(state.tempWorktreePath, 'tempWorktreePath'),
|
|
1400
|
+
sourceBranch: normalizeIntegrateBranch(state.sourceBranch, 'sourceBranch'),
|
|
1401
|
+
targetBranch: normalizeIntegrateBranch(state.targetBranch, 'targetBranch'),
|
|
1402
|
+
cleanTargetWorktrees: Array.isArray(state.cleanTargetWorktrees)
|
|
1403
|
+
? state.cleanTargetWorktrees.map((entry) => normalizeIntegratePath(entry, 'cleanTargetWorktree'))
|
|
1404
|
+
: [],
|
|
1405
|
+
remainingCommits: Array.isArray(state.remainingCommits) ? state.remainingCommits.map(normalizeIntegrateSha) : [],
|
|
1406
|
+
currentCommit: normalizeIntegrateSha(state.currentCommit),
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
export async function integrateWorktreeCommits(inputPlan = {}) {
|
|
1410
|
+
const plan = await normalizeIntegratePlan(inputPlan);
|
|
1411
|
+
if (plan.commits.length === 0) {
|
|
1412
|
+
return { kind: 'noop', reason: 'No commits to move' };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const tmpDir = await createIntegrateTempWorktree(plan.repoRoot, plan.targetBranch);
|
|
1416
|
+
let cleanTargetWorktrees = [];
|
|
1417
|
+
let remaining = [];
|
|
1418
|
+
try {
|
|
1419
|
+
await maybeFastForwardIntegrateUpstream(tmpDir);
|
|
1420
|
+
|
|
1421
|
+
const clean = await runGitCommand(tmpDir, ['status', '--porcelain']);
|
|
1422
|
+
if (gitStdoutText(clean)) {
|
|
1423
|
+
throw new Error('Target branch has local changes; abort integration and retry');
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
cleanTargetWorktrees = await computeCleanIntegrateWorktreesToSync({
|
|
1427
|
+
repoRoot: plan.repoRoot,
|
|
1428
|
+
targetBranch: plan.targetBranch,
|
|
1429
|
+
excludePaths: [tmpDir],
|
|
1430
|
+
}).catch(() => []);
|
|
1431
|
+
|
|
1432
|
+
remaining = [...plan.commits];
|
|
1433
|
+
while (remaining.length > 0) {
|
|
1434
|
+
const sha = remaining[0];
|
|
1435
|
+
const pick = await runGitCommand(tmpDir, ['cherry-pick', sha]);
|
|
1436
|
+
if (runGitOk(pick)) {
|
|
1437
|
+
remaining.shift();
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const unmerged = await runGitCommand(tmpDir, ['diff', '--name-only', '--diff-filter=U']);
|
|
1442
|
+
const unmergedFiles = trimGitLines(unmerged.stdout);
|
|
1443
|
+
if (unmergedFiles.length > 0) {
|
|
1444
|
+
const details = await getIntegrateConflictDetails(tmpDir);
|
|
1445
|
+
return {
|
|
1446
|
+
kind: 'conflict',
|
|
1447
|
+
state: {
|
|
1448
|
+
repoRoot: plan.repoRoot,
|
|
1449
|
+
tempWorktreePath: tmpDir,
|
|
1450
|
+
sourceBranch: plan.sourceBranch,
|
|
1451
|
+
targetBranch: plan.targetBranch,
|
|
1452
|
+
cleanTargetWorktrees,
|
|
1453
|
+
remainingCommits: remaining,
|
|
1454
|
+
currentCommit: sha,
|
|
1455
|
+
},
|
|
1456
|
+
details,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
throw new Error(gitStderrText(pick) || 'Cherry-pick failed');
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
await removeIntegrateTempWorktree(plan.repoRoot, tmpDir);
|
|
1464
|
+
await syncCleanIntegrateTargetWorktrees(cleanTargetWorktrees).catch(() => undefined);
|
|
1465
|
+
return { kind: 'success', moved: plan.commits.length };
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
await removeIntegrateTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined);
|
|
1468
|
+
throw error;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export async function abortIntegrate(stateInput = {}) {
|
|
1473
|
+
const state = normalizeIntegrateState(stateInput);
|
|
1474
|
+
await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--abort']).catch(() => undefined);
|
|
1475
|
+
await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath);
|
|
1476
|
+
return { success: true };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
export async function continueIntegrate(stateInput = {}) {
|
|
1480
|
+
const state = normalizeIntegrateState(stateInput);
|
|
1481
|
+
const cont = await runGitCommand(state.tempWorktreePath, ['cherry-pick', '--continue']);
|
|
1482
|
+
if (!runGitOk(cont)) {
|
|
1483
|
+
const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']);
|
|
1484
|
+
if (trimGitLines(unmerged.stdout).length > 0) {
|
|
1485
|
+
const details = await getIntegrateConflictDetails(state.tempWorktreePath);
|
|
1486
|
+
return { kind: 'conflict', state, details };
|
|
1487
|
+
}
|
|
1488
|
+
throw new Error(gitStderrText(cont) || 'Cherry-pick continue failed');
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const remaining = [...state.remainingCommits];
|
|
1492
|
+
if (remaining.length > 0 && remaining[0] === state.currentCommit) {
|
|
1493
|
+
remaining.shift();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const still = [...remaining];
|
|
1497
|
+
while (still.length > 0) {
|
|
1498
|
+
const sha = still[0];
|
|
1499
|
+
const pick = await runGitCommand(state.tempWorktreePath, ['cherry-pick', sha]);
|
|
1500
|
+
if (runGitOk(pick)) {
|
|
1501
|
+
still.shift();
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
const unmerged = await runGitCommand(state.tempWorktreePath, ['diff', '--name-only', '--diff-filter=U']);
|
|
1505
|
+
if (trimGitLines(unmerged.stdout).length > 0) {
|
|
1506
|
+
const details = await getIntegrateConflictDetails(state.tempWorktreePath);
|
|
1507
|
+
return {
|
|
1508
|
+
kind: 'conflict',
|
|
1509
|
+
state: {
|
|
1510
|
+
...state,
|
|
1511
|
+
remainingCommits: still,
|
|
1512
|
+
currentCommit: sha,
|
|
1513
|
+
},
|
|
1514
|
+
details,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
throw new Error(gitStderrText(pick) || 'Cherry-pick failed');
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
await removeIntegrateTempWorktree(state.repoRoot, state.tempWorktreePath);
|
|
1521
|
+
await syncCleanIntegrateTargetWorktrees(state.cleanTargetWorktrees).catch(() => undefined);
|
|
1522
|
+
return { kind: 'success', moved: state.remainingCommits.length };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const ensureOpenCodeProjectId = async (primaryWorktree) => {
|
|
1526
|
+
const gitDir = path.join(primaryWorktree, '.git');
|
|
1527
|
+
const idFile = path.join(gitDir, 'opencode');
|
|
1528
|
+
const existing = await fsp.readFile(idFile, 'utf8').then((value) => value.trim()).catch(() => '');
|
|
1529
|
+
if (existing) {
|
|
1530
|
+
return existing;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const rootsResult = await runGitCommandOrThrow(
|
|
1534
|
+
primaryWorktree,
|
|
1535
|
+
['rev-list', '--max-parents=0', '--all'],
|
|
1536
|
+
'Failed to resolve repository roots'
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
const roots = rootsResult.stdout
|
|
1540
|
+
.split('\n')
|
|
1541
|
+
.map((line) => line.trim())
|
|
1542
|
+
.filter(Boolean)
|
|
1543
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1544
|
+
|
|
1545
|
+
const projectId = roots[0] || '';
|
|
1546
|
+
if (!projectId) {
|
|
1547
|
+
throw new Error('Failed to derive OpenCode project ID');
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
await fsp.mkdir(gitDir, { recursive: true }).catch(() => undefined);
|
|
1551
|
+
await fsp.writeFile(idFile, projectId, 'utf8').catch(() => undefined);
|
|
1552
|
+
|
|
1553
|
+
return projectId;
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
const resolveWorktreeProjectContext = async (directory) => {
|
|
1557
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
1558
|
+
if (!directoryPath) {
|
|
1559
|
+
throw new Error('Directory is required');
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const topResult = await runGitCommandOrThrow(
|
|
1563
|
+
directoryPath,
|
|
1564
|
+
['rev-parse', '--show-toplevel'],
|
|
1565
|
+
'Failed to resolve git top-level directory'
|
|
1566
|
+
);
|
|
1567
|
+
const sandbox = path.resolve(directoryPath, topResult.stdout.trim());
|
|
1568
|
+
|
|
1569
|
+
const commonResult = await runGitCommandOrThrow(
|
|
1570
|
+
sandbox,
|
|
1571
|
+
['rev-parse', '--git-common-dir'],
|
|
1572
|
+
'Failed to resolve git common directory'
|
|
1573
|
+
);
|
|
1574
|
+
const commonDir = path.resolve(sandbox, commonResult.stdout.trim());
|
|
1575
|
+
const primaryWorktree = path.dirname(commonDir);
|
|
1576
|
+
const projectID = await ensureOpenCodeProjectId(primaryWorktree);
|
|
1577
|
+
const worktreeRoot = path.join(getOpenCodeDataPath(), 'worktree', projectID);
|
|
1578
|
+
|
|
1579
|
+
return {
|
|
1580
|
+
projectID,
|
|
1581
|
+
sandbox,
|
|
1582
|
+
primaryWorktree,
|
|
1583
|
+
worktreeRoot,
|
|
1584
|
+
};
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
const listWorktreeEntries = async (directory) => {
|
|
1588
|
+
const rawResult = await runGitCommandOrThrow(
|
|
1589
|
+
directory,
|
|
1590
|
+
['worktree', 'list', '--porcelain'],
|
|
1591
|
+
'Failed to list git worktrees'
|
|
1592
|
+
);
|
|
1593
|
+
return parseWorktreePorcelain(rawResult.stdout);
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
const resolveWorktreeNameCandidates = (baseName) => {
|
|
1597
|
+
const normalizedBase = slugWorktreeName(baseName || '');
|
|
1598
|
+
if (!normalizedBase) {
|
|
1599
|
+
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, () => generateOpenCodeRandomName());
|
|
1600
|
+
}
|
|
1601
|
+
return Array.from({ length: OPENCODE_WORKTREE_ATTEMPTS }, (_, index) => {
|
|
1602
|
+
if (index === 0) {
|
|
1603
|
+
return normalizedBase;
|
|
1604
|
+
}
|
|
1605
|
+
return `${normalizedBase}-${generateOpenCodeRandomName()}`;
|
|
1606
|
+
});
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
const resolveCandidateDirectory = async (worktreeRoot, preferredName, explicitBranchName, primaryWorktree) => {
|
|
1610
|
+
const candidates = resolveWorktreeNameCandidates(preferredName);
|
|
1611
|
+
|
|
1612
|
+
for (const name of candidates) {
|
|
1613
|
+
const directory = path.join(worktreeRoot, name);
|
|
1614
|
+
if (await checkPathExists(directory)) {
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (explicitBranchName) {
|
|
1619
|
+
return { name, directory, branch: explicitBranchName };
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const branch = `pichamber/${name}`;
|
|
1623
|
+
const branchRef = `refs/heads/${branch}`;
|
|
1624
|
+
const branchExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', branchRef]);
|
|
1625
|
+
if (branchExists.success) {
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
return { name, directory, branch };
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
throw new Error('Failed to generate a unique worktree name');
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
const resolveBranchForExistingMode = async (primaryWorktree, existingBranch, preferredBranchName) => {
|
|
1636
|
+
const requested = String(existingBranch || '').trim();
|
|
1637
|
+
if (!requested) {
|
|
1638
|
+
throw new Error('existingBranch is required in existing mode');
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const normalizedLocal = cleanBranchName(requested);
|
|
1642
|
+
const localRef = `refs/heads/${normalizedLocal}`;
|
|
1643
|
+
const localExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', localRef]);
|
|
1644
|
+
if (localExists.success) {
|
|
1645
|
+
return {
|
|
1646
|
+
localBranch: normalizedLocal,
|
|
1647
|
+
checkoutRef: normalizedLocal,
|
|
1648
|
+
createLocalBranch: false,
|
|
1649
|
+
remoteRef: null,
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const remoteRef = parseRemoteBranchRef(requested);
|
|
1654
|
+
if (!remoteRef) {
|
|
1655
|
+
throw new Error(`Branch not found: ${requested}`);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const remoteExists = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
|
|
1659
|
+
if (!remoteExists.success) {
|
|
1660
|
+
await fetchRemoteBranchRef(primaryWorktree, remoteRef.remote, remoteRef.branch).catch(() => undefined);
|
|
1661
|
+
const recheck = await runGitCommand(primaryWorktree, ['show-ref', '--verify', '--quiet', remoteRef.fullRef]);
|
|
1662
|
+
if (!recheck.success) {
|
|
1663
|
+
throw new Error(`Remote branch not found: ${requested}`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
const localBranch = cleanBranchName(preferredBranchName || remoteRef.branch || requested);
|
|
1668
|
+
if (!localBranch) {
|
|
1669
|
+
throw new Error('Failed to resolve local branch name for existing branch worktree');
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
return {
|
|
1673
|
+
localBranch,
|
|
1674
|
+
checkoutRef: remoteRef.remoteRef,
|
|
1675
|
+
createLocalBranch: true,
|
|
1676
|
+
remoteRef,
|
|
1677
|
+
};
|
|
1678
|
+
};
|
|
1679
|
+
|
|
1680
|
+
const findBranchInUse = async (primaryWorktree, localBranchName) => {
|
|
1681
|
+
if (!localBranchName) {
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
const entries = await listWorktreeEntries(primaryWorktree);
|
|
1685
|
+
const targetRef = `refs/heads/${localBranchName}`;
|
|
1686
|
+
const targetClean = cleanBranchName(targetRef);
|
|
1687
|
+
return entries.find((entry) => {
|
|
1688
|
+
const entryRef = String(entry.branchRef || '').trim();
|
|
1689
|
+
const entryClean = cleanBranchName(entryRef || entry.branch || '');
|
|
1690
|
+
return entryRef === targetRef || entryClean === targetClean;
|
|
1691
|
+
}) || null;
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
const runWorktreeStartCommand = async (directory, command) => {
|
|
1695
|
+
const text = String(command || '').trim();
|
|
1696
|
+
if (!text) {
|
|
1697
|
+
return { success: true };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (process.platform === 'win32') {
|
|
1701
|
+
const result = await execFileAsync('cmd', ['/c', text], {
|
|
1702
|
+
cwd: directory,
|
|
1703
|
+
env: await buildGitEnv(),
|
|
1704
|
+
windowsHide: true,
|
|
1705
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
1706
|
+
}).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
|
|
1707
|
+
success: false,
|
|
1708
|
+
stdout: error?.stdout,
|
|
1709
|
+
stderr: error?.stderr,
|
|
1710
|
+
message: parseGitErrorText(error),
|
|
1711
|
+
}));
|
|
1712
|
+
return result;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const result = await execFileAsync('bash', ['-lc', text], {
|
|
1716
|
+
cwd: directory,
|
|
1717
|
+
env: await buildGitEnv(),
|
|
1718
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
1719
|
+
}).then(({ stdout, stderr }) => ({ success: true, stdout, stderr })).catch((error) => ({
|
|
1720
|
+
success: false,
|
|
1721
|
+
stdout: error?.stdout,
|
|
1722
|
+
stderr: error?.stderr,
|
|
1723
|
+
message: parseGitErrorText(error),
|
|
1724
|
+
}));
|
|
1725
|
+
return result;
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
const loadProjectStartCommand = async (projectID) => {
|
|
1729
|
+
const storagePath = path.join(getOpenCodeDataPath(), 'storage', 'project', `${projectID}.json`);
|
|
1730
|
+
try {
|
|
1731
|
+
const raw = await fsp.readFile(storagePath, 'utf8');
|
|
1732
|
+
const parsed = JSON.parse(raw);
|
|
1733
|
+
const start = typeof parsed?.commands?.start === 'string' ? parsed.commands.start.trim() : '';
|
|
1734
|
+
return start || '';
|
|
1735
|
+
} catch {
|
|
1736
|
+
return '';
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
// OpenCode owns its own project/sandbox registry. It records a worktree as a
|
|
1741
|
+
// sandbox itself when an instance boots for that directory, and filters entries
|
|
1742
|
+
// whose directory no longer exists when reading them back. PiChamber used to
|
|
1743
|
+
// write that state directly into OpenCode's storage JSON and SQLite database,
|
|
1744
|
+
// behind the back of the running process: the row changed but the server was
|
|
1745
|
+
// never told, so a worktree created while OpenCode was running stayed unknown
|
|
1746
|
+
// to it until a restart. Registration is not ours to perform.
|
|
1747
|
+
|
|
1748
|
+
const isAttachedGitWorktreeDirectory = async (directory) => {
|
|
1749
|
+
try {
|
|
1750
|
+
const result = await runGitCommand(directory, ['rev-parse', '--is-inside-work-tree']);
|
|
1751
|
+
return result.success && String(result.stdout || '').trim() === 'true';
|
|
1752
|
+
} catch {
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
const cleanupFailedFastWorktreeCreate = async (context, candidate) => {
|
|
1758
|
+
const candidateDirectory = path.resolve(candidate.directory);
|
|
1759
|
+
const worktreeRoot = path.resolve(context.worktreeRoot);
|
|
1760
|
+
const isInsideWorktreeRoot = isInsideOrSameDirectory(worktreeRoot, candidateDirectory) && candidateDirectory !== worktreeRoot;
|
|
1761
|
+
const isAttached = await isAttachedGitWorktreeDirectory(candidateDirectory);
|
|
1762
|
+
|
|
1763
|
+
if (!isInsideWorktreeRoot || isAttached) {
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
try {
|
|
1768
|
+
const entries = await fsp.readdir(candidateDirectory);
|
|
1769
|
+
if (entries.length === 0) {
|
|
1770
|
+
await fsp.rmdir(candidateDirectory);
|
|
1771
|
+
}
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) {
|
|
1774
|
+
console.warn('Failed to clean up empty worktree directory after creation failure:', error instanceof Error ? error.message : String(error));
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
const runWorktreeStartScripts = async (directory, projectID, startCommand) => {
|
|
1780
|
+
const projectStart = await loadProjectStartCommand(projectID);
|
|
1781
|
+
if (projectStart) {
|
|
1782
|
+
const projectResult = await runWorktreeStartCommand(directory, projectStart);
|
|
1783
|
+
if (!projectResult.success) {
|
|
1784
|
+
console.warn('Worktree project start command failed:', projectResult.message || projectResult.stderr || projectResult.stdout);
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const extraCommand = String(startCommand || '').trim();
|
|
1790
|
+
if (!extraCommand) {
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const extraResult = await runWorktreeStartCommand(directory, extraCommand);
|
|
1794
|
+
if (!extraResult.success) {
|
|
1795
|
+
console.warn('Worktree start command failed:', extraResult.message || extraResult.stderr || extraResult.stdout);
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
const queueWorktreeBootstrap = (args) => {
|
|
1800
|
+
const {
|
|
1801
|
+
directory,
|
|
1802
|
+
projectID,
|
|
1803
|
+
primaryWorktree,
|
|
1804
|
+
localBranch,
|
|
1805
|
+
setUpstream,
|
|
1806
|
+
upstreamRemote,
|
|
1807
|
+
upstreamBranch,
|
|
1808
|
+
ensureRemoteName,
|
|
1809
|
+
ensureRemoteUrl,
|
|
1810
|
+
startCommand,
|
|
1811
|
+
} = args;
|
|
1812
|
+
const task = new Promise((resolve) => setTimeout(resolve, 0))
|
|
1813
|
+
.then(async () => {
|
|
1814
|
+
await populateWorktreeWithLockRecovery(directory);
|
|
1815
|
+
await runPostCheckoutHook(directory);
|
|
1816
|
+
if (setUpstream) {
|
|
1817
|
+
await applyUpstreamConfiguration({
|
|
1818
|
+
primaryWorktree,
|
|
1819
|
+
worktreeDirectory: directory,
|
|
1820
|
+
localBranch,
|
|
1821
|
+
setUpstream,
|
|
1822
|
+
upstreamRemote,
|
|
1823
|
+
upstreamBranch,
|
|
1824
|
+
ensureRemoteName,
|
|
1825
|
+
ensureRemoteUrl,
|
|
1826
|
+
}).catch((error) => {
|
|
1827
|
+
console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
setWorktreeBootstrapState(
|
|
1831
|
+
directory,
|
|
1832
|
+
WORKTREE_BOOTSTRAP_PENDING,
|
|
1833
|
+
WORKTREE_BOOTSTRAP_PHASE_GIT_READY
|
|
1834
|
+
);
|
|
1835
|
+
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
|
|
1836
|
+
console.warn('Worktree start script task failed:', error instanceof Error ? error.message : String(error));
|
|
1837
|
+
});
|
|
1838
|
+
setWorktreeBootstrapState(
|
|
1839
|
+
directory,
|
|
1840
|
+
WORKTREE_BOOTSTRAP_READY,
|
|
1841
|
+
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
|
|
1842
|
+
);
|
|
1843
|
+
})
|
|
1844
|
+
.catch((error) => {
|
|
1845
|
+
setWorktreeBootstrapState(
|
|
1846
|
+
directory,
|
|
1847
|
+
WORKTREE_BOOTSTRAP_FAILED,
|
|
1848
|
+
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
|
|
1849
|
+
error instanceof Error ? error.message : String(error)
|
|
1850
|
+
);
|
|
1851
|
+
console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
trackWorktreeBootstrapTask(directory, task);
|
|
1855
|
+
};
|
|
1856
|
+
|
|
1857
|
+
const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => {
|
|
1858
|
+
const name = String(remoteName || '').trim();
|
|
1859
|
+
const url = String(remoteUrl || '').trim();
|
|
1860
|
+
if (!name || !url) {
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const getUrl = await runGitCommand(primaryWorktree, ['remote', 'get-url', name]);
|
|
1865
|
+
if (getUrl.success) {
|
|
1866
|
+
const currentUrl = String(getUrl.stdout || '').trim();
|
|
1867
|
+
if (currentUrl !== url) {
|
|
1868
|
+
await runGitCommandOrThrow(primaryWorktree, ['remote', 'set-url', name, url], 'Failed to update git remote URL');
|
|
1869
|
+
}
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
await runGitCommandOrThrow(primaryWorktree, ['remote', 'add', name, url], 'Failed to add git remote');
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1876
|
+
const fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => {
|
|
1877
|
+
const remote = String(remoteName || '').trim();
|
|
1878
|
+
const branch = String(branchName || '').trim();
|
|
1879
|
+
if (!remote || !branch) {
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const refspec = `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`;
|
|
1884
|
+
await runGitCommandOrThrow(
|
|
1885
|
+
primaryWorktree,
|
|
1886
|
+
['fetch', remote, refspec],
|
|
1887
|
+
`Failed to fetch ${remote}/${branch}`
|
|
1888
|
+
);
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => {
|
|
1892
|
+
const remote = String(remoteName || '').trim();
|
|
1893
|
+
const branch = String(branchName || '').trim();
|
|
1894
|
+
const url = String(remoteUrl || '').trim();
|
|
1895
|
+
if (!remote || !branch) {
|
|
1896
|
+
return { success: false, found: false };
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const target = url || remote;
|
|
1900
|
+
const lsRemote = await runGitCommand(
|
|
1901
|
+
primaryWorktree,
|
|
1902
|
+
['ls-remote', '--heads', target, `refs/heads/${branch}`]
|
|
1903
|
+
);
|
|
1904
|
+
if (!lsRemote.success) {
|
|
1905
|
+
return { success: false, found: false };
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
return {
|
|
1909
|
+
success: true,
|
|
1910
|
+
found: Boolean(String(lsRemote.stdout || '').trim()),
|
|
1911
|
+
};
|
|
1912
|
+
};
|
|
1913
|
+
|
|
1914
|
+
const setBranchTrackingFallback = async (worktreeDirectory, localBranch, upstream) => {
|
|
1915
|
+
await runGitCommandOrThrow(
|
|
1916
|
+
worktreeDirectory,
|
|
1917
|
+
['config', `branch.${localBranch}.remote`, upstream.remote],
|
|
1918
|
+
`Failed to set branch.${localBranch}.remote`
|
|
1919
|
+
);
|
|
1920
|
+
await runGitCommandOrThrow(
|
|
1921
|
+
worktreeDirectory,
|
|
1922
|
+
['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`],
|
|
1923
|
+
`Failed to set branch.${localBranch}.merge`
|
|
1924
|
+
);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
const applyUpstreamConfiguration = async (args) => {
|
|
1928
|
+
const {
|
|
1929
|
+
primaryWorktree,
|
|
1930
|
+
worktreeDirectory,
|
|
1931
|
+
localBranch,
|
|
1932
|
+
setUpstream,
|
|
1933
|
+
upstreamRemote,
|
|
1934
|
+
upstreamBranch,
|
|
1935
|
+
ensureRemoteName,
|
|
1936
|
+
ensureRemoteUrl,
|
|
1937
|
+
} = args;
|
|
1938
|
+
|
|
1939
|
+
if (!setUpstream) {
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (ensureRemoteName && ensureRemoteUrl) {
|
|
1944
|
+
await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
const upstream = normalizeUpstreamTarget(upstreamRemote, upstreamBranch);
|
|
1948
|
+
if (!upstream || !localBranch) {
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
let fetched = true;
|
|
1953
|
+
try {
|
|
1954
|
+
await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
|
|
1955
|
+
} catch {
|
|
1956
|
+
fetched = false;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (fetched) {
|
|
1960
|
+
await runGitCommandOrThrow(
|
|
1961
|
+
worktreeDirectory,
|
|
1962
|
+
['branch', `--set-upstream-to=${upstream.full}`, localBranch],
|
|
1963
|
+
`Failed to set upstream to ${upstream.full}`
|
|
1964
|
+
);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream);
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
export async function isGitRepository(directory) {
|
|
1972
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
1973
|
+
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']);
|
|
1978
|
+
return result.success;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
export async function getGlobalIdentity() {
|
|
1982
|
+
const git = await createGitForGlobalConfig();
|
|
1983
|
+
|
|
1984
|
+
try {
|
|
1985
|
+
const userName = await git.getConfig('user.name', 'global').catch(() => null);
|
|
1986
|
+
const userEmail = await git.getConfig('user.email', 'global').catch(() => null);
|
|
1987
|
+
const sshCommand = await git.getConfig('core.sshCommand', 'global').catch(() => null);
|
|
1988
|
+
|
|
1989
|
+
return {
|
|
1990
|
+
userName: userName?.value || null,
|
|
1991
|
+
userEmail: userEmail?.value || null,
|
|
1992
|
+
sshCommand: sshCommand?.value || null
|
|
1993
|
+
};
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
console.error('Failed to get global Git identity:', error);
|
|
1996
|
+
return {
|
|
1997
|
+
userName: null,
|
|
1998
|
+
userEmail: null,
|
|
1999
|
+
sshCommand: null
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
export async function getRemoteUrl(directory, remoteName = 'origin') {
|
|
2005
|
+
const git = await createGit(directory);
|
|
2006
|
+
|
|
2007
|
+
try {
|
|
2008
|
+
const url = await git.remote(['get-url', remoteName]);
|
|
2009
|
+
return url?.trim() || null;
|
|
2010
|
+
} catch {
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
export async function getCurrentIdentity(directory) {
|
|
2016
|
+
const git = await createGit(directory);
|
|
2017
|
+
|
|
2018
|
+
try {
|
|
2019
|
+
|
|
2020
|
+
const userName = await git.getConfig('user.name', 'local').catch(() =>
|
|
2021
|
+
git.getConfig('user.name', 'global')
|
|
2022
|
+
);
|
|
2023
|
+
|
|
2024
|
+
const userEmail = await git.getConfig('user.email', 'local').catch(() =>
|
|
2025
|
+
git.getConfig('user.email', 'global')
|
|
2026
|
+
);
|
|
2027
|
+
|
|
2028
|
+
const sshCommand = await git.getConfig('core.sshCommand', 'local').catch(() =>
|
|
2029
|
+
git.getConfig('core.sshCommand', 'global')
|
|
2030
|
+
);
|
|
2031
|
+
|
|
2032
|
+
return {
|
|
2033
|
+
userName: userName?.value || null,
|
|
2034
|
+
userEmail: userEmail?.value || null,
|
|
2035
|
+
sshCommand: sshCommand?.value || null
|
|
2036
|
+
};
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
console.error('Failed to get current Git identity:', error);
|
|
2039
|
+
return {
|
|
2040
|
+
userName: null,
|
|
2041
|
+
userEmail: null,
|
|
2042
|
+
sshCommand: null
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
export async function hasLocalIdentity(directory) {
|
|
2048
|
+
const git = await createGit(directory);
|
|
2049
|
+
|
|
2050
|
+
try {
|
|
2051
|
+
const localName = await git.getConfig('user.name', 'local').catch(() => null);
|
|
2052
|
+
const localEmail = await git.getConfig('user.email', 'local').catch(() => null);
|
|
2053
|
+
return Boolean(localName?.value || localEmail?.value);
|
|
2054
|
+
} catch {
|
|
2055
|
+
return false;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
export async function setLocalIdentity(directory, profile) {
|
|
2060
|
+
const git = await createGit(directory, { allowUnsafeSshCommand: true });
|
|
2061
|
+
|
|
2062
|
+
try {
|
|
2063
|
+
|
|
2064
|
+
await git.addConfig('user.name', profile.userName, false, 'local');
|
|
2065
|
+
await git.addConfig('user.email', profile.userEmail, false, 'local');
|
|
2066
|
+
|
|
2067
|
+
const authType = profile.authType || 'ssh';
|
|
2068
|
+
|
|
2069
|
+
if (authType === 'ssh' && profile.sshKey) {
|
|
2070
|
+
await git.raw([
|
|
2071
|
+
'config',
|
|
2072
|
+
'--local',
|
|
2073
|
+
'core.sshCommand',
|
|
2074
|
+
buildSshCommand(profile.sshKey)
|
|
2075
|
+
]);
|
|
2076
|
+
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
|
|
2077
|
+
} else if (authType === 'token' && profile.host) {
|
|
2078
|
+
await git.addConfig(
|
|
2079
|
+
'credential.helper',
|
|
2080
|
+
'store',
|
|
2081
|
+
false,
|
|
2082
|
+
'local'
|
|
2083
|
+
);
|
|
2084
|
+
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (profile.signCommits === true && typeof profile.signingKey === 'string' && profile.signingKey.trim()) {
|
|
2088
|
+
await git.addConfig('gpg.format', 'ssh', false, 'local');
|
|
2089
|
+
await git.addConfig('user.signingkey', profile.signingKey.trim(), false, 'local');
|
|
2090
|
+
await git.addConfig('commit.gpgsign', 'true', false, 'local');
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
return true;
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
console.error('Failed to set Git identity:', error);
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
export async function getStatus(directory, options = {}) {
|
|
2101
|
+
const lightMode = options.mode === 'light';
|
|
2102
|
+
const normalizedDirectory = normalizeDirectoryPath(directory);
|
|
2103
|
+
if (typeof normalizedDirectory !== 'string' || !normalizedDirectory.trim()) {
|
|
2104
|
+
throw new Error('directory is required');
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
try {
|
|
2108
|
+
// Prefer an explicit non-repo check before simple-git status so a missing
|
|
2109
|
+
// repository never depends on process.cwd() or an opaque GitError shape.
|
|
2110
|
+
if (!(await isGitRepository(normalizedDirectory))) {
|
|
2111
|
+
throw new Error('fatal: not a git repository (or any of the parent directories): .git');
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory);
|
|
2115
|
+
|
|
2116
|
+
// Use -uall to show all untracked files individually, not just directories
|
|
2117
|
+
const status = await git.status(['-uall']);
|
|
2118
|
+
|
|
2119
|
+
// Light mode: skip numstat + new-file line counting for faster response
|
|
2120
|
+
const [stagedStatsRaw, workingStatsRaw] = lightMode
|
|
2121
|
+
? ['', '']
|
|
2122
|
+
: await Promise.all([
|
|
2123
|
+
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
|
|
2124
|
+
git.raw(['diff', '--numstat']).catch(() => ''),
|
|
2125
|
+
]);
|
|
2126
|
+
|
|
2127
|
+
const diffStatsMap = new Map();
|
|
2128
|
+
|
|
2129
|
+
const accumulateStats = (raw) => {
|
|
2130
|
+
if (!raw) return;
|
|
2131
|
+
raw
|
|
2132
|
+
.split('\n')
|
|
2133
|
+
.map((line) => line.trim())
|
|
2134
|
+
.filter(Boolean)
|
|
2135
|
+
.forEach((line) => {
|
|
2136
|
+
const parts = line.split('\t');
|
|
2137
|
+
if (parts.length < 3) {
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
|
2141
|
+
const path = pathParts.join('\t');
|
|
2142
|
+
if (!path) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
|
2146
|
+
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
|
2147
|
+
|
|
2148
|
+
const existing = diffStatsMap.get(path) || { insertions: 0, deletions: 0 };
|
|
2149
|
+
diffStatsMap.set(path, {
|
|
2150
|
+
insertions: existing.insertions + insertions,
|
|
2151
|
+
deletions: existing.deletions + deletions,
|
|
2152
|
+
});
|
|
2153
|
+
});
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
accumulateStats(stagedStatsRaw);
|
|
2157
|
+
accumulateStats(workingStatsRaw);
|
|
2158
|
+
|
|
2159
|
+
const diffStats = Object.fromEntries(diffStatsMap.entries());
|
|
2160
|
+
|
|
2161
|
+
const MAX_NEW_FILE_STATS = 200;
|
|
2162
|
+
const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
|
|
2163
|
+
const newFileStats = [];
|
|
2164
|
+
|
|
2165
|
+
if (!lightMode) {
|
|
2166
|
+
for (const file of status.files) {
|
|
2167
|
+
if (newFileStats.length >= MAX_NEW_FILE_STATS) {
|
|
2168
|
+
break;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const working = (file.working_dir || '').trim();
|
|
2172
|
+
const indexStatus = (file.index || '').trim();
|
|
2173
|
+
const statusCode = working || indexStatus;
|
|
2174
|
+
|
|
2175
|
+
if (statusCode !== '?' && statusCode !== 'A') {
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
const existing = diffStats[file.path];
|
|
2180
|
+
if (existing && existing.insertions > 0) {
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const absolutePath = path.join(repoRoot, file.path);
|
|
2185
|
+
|
|
2186
|
+
try {
|
|
2187
|
+
const stat = await fsp.stat(absolutePath);
|
|
2188
|
+
if (!stat.isFile() || stat.size > MAX_NEW_FILE_STAT_SIZE) {
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const buffer = await fsp.readFile(absolutePath);
|
|
2193
|
+
if (buffer.indexOf(0) !== -1) {
|
|
2194
|
+
newFileStats.push({
|
|
2195
|
+
path: file.path,
|
|
2196
|
+
insertions: existing?.insertions ?? 0,
|
|
2197
|
+
deletions: existing?.deletions ?? 0,
|
|
2198
|
+
});
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
|
|
2203
|
+
if (!normalized.length) {
|
|
2204
|
+
newFileStats.push({
|
|
2205
|
+
path: file.path,
|
|
2206
|
+
insertions: 0,
|
|
2207
|
+
deletions: 0,
|
|
2208
|
+
});
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
const segments = normalized.split('\n');
|
|
2213
|
+
if (normalized.endsWith('\n')) {
|
|
2214
|
+
segments.pop();
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const lineCount = segments.length;
|
|
2218
|
+
newFileStats.push({
|
|
2219
|
+
path: file.path,
|
|
2220
|
+
insertions: lineCount,
|
|
2221
|
+
deletions: 0,
|
|
2222
|
+
});
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
if (error?.code !== 'ENOENT') {
|
|
2225
|
+
console.warn('Failed to estimate diff stats for new file', file.path, error);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
for (const entry of newFileStats) {
|
|
2232
|
+
diffStats[entry.path] = {
|
|
2233
|
+
insertions: entry.insertions,
|
|
2234
|
+
deletions: entry.deletions,
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const selectBaseRefForUnpublished = async () => {
|
|
2239
|
+
const candidates = [];
|
|
2240
|
+
|
|
2241
|
+
const originHead = await git
|
|
2242
|
+
.raw(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'])
|
|
2243
|
+
.then((value) => String(value || '').trim())
|
|
2244
|
+
.catch(() => '');
|
|
2245
|
+
|
|
2246
|
+
if (originHead) {
|
|
2247
|
+
// "refs/remotes/origin/main" -> "origin/main"
|
|
2248
|
+
candidates.push(originHead.replace(/^refs\/remotes\//, ''));
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
candidates.push('origin/main', 'origin/master', 'main', 'master');
|
|
2252
|
+
|
|
2253
|
+
for (const ref of candidates) {
|
|
2254
|
+
const exists = await git
|
|
2255
|
+
.raw(['rev-parse', '--verify', ref])
|
|
2256
|
+
.then((value) => String(value || '').trim())
|
|
2257
|
+
.catch(() => '');
|
|
2258
|
+
if (exists) return ref;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
return null;
|
|
2262
|
+
};
|
|
2263
|
+
|
|
2264
|
+
let tracking = status.tracking || null;
|
|
2265
|
+
let ahead = status.ahead;
|
|
2266
|
+
let behind = status.behind;
|
|
2267
|
+
let upstreamComparison;
|
|
2268
|
+
|
|
2269
|
+
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
|
|
2270
|
+
// We still want to show the number of unpublished commits to the user.
|
|
2271
|
+
// Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
|
|
2272
|
+
if (!lightMode && !tracking && status.current) {
|
|
2273
|
+
const baseRef = await selectBaseRefForUnpublished();
|
|
2274
|
+
if (baseRef) {
|
|
2275
|
+
const countRaw = await git
|
|
2276
|
+
.raw(['rev-list', '--count', `${baseRef}..HEAD`])
|
|
2277
|
+
.then((value) => String(value || '').trim())
|
|
2278
|
+
.catch(() => '');
|
|
2279
|
+
const count = parseInt(countRaw, 10);
|
|
2280
|
+
if (Number.isFinite(count)) {
|
|
2281
|
+
ahead = count;
|
|
2282
|
+
behind = 0;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
if (
|
|
2288
|
+
!lightMode
|
|
2289
|
+
&& status.current
|
|
2290
|
+
&& (!tracking || !tracking.startsWith('upstream/'))
|
|
2291
|
+
&& await hasRemote(git, directoryPath, 'upstream')
|
|
2292
|
+
) {
|
|
2293
|
+
upstreamComparison = await getRemoteBranchComparison(git, 'upstream', status.current);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// Check for in-progress operations
|
|
2297
|
+
let mergeInProgress = null;
|
|
2298
|
+
let rebaseInProgress = null;
|
|
2299
|
+
|
|
2300
|
+
try {
|
|
2301
|
+
// Check MERGE_HEAD for merge in progress
|
|
2302
|
+
const mergeHeadExists = await git
|
|
2303
|
+
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
|
2304
|
+
.then(() => true)
|
|
2305
|
+
.catch(() => false);
|
|
2306
|
+
|
|
2307
|
+
if (mergeHeadExists) {
|
|
2308
|
+
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
|
2309
|
+
const headSha = mergeHead.trim().slice(0, 7);
|
|
2310
|
+
// Only set mergeInProgress if we actually have a valid head SHA
|
|
2311
|
+
if (headSha) {
|
|
2312
|
+
const mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => '');
|
|
2313
|
+
const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, 'utf8').catch(() => '') : '';
|
|
2314
|
+
mergeInProgress = {
|
|
2315
|
+
head: headSha,
|
|
2316
|
+
message: mergeMsg.split('\n')[0] || '',
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
} catch {
|
|
2321
|
+
// ignore
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
try {
|
|
2325
|
+
// Check for rebase in progress (.git/rebase-merge or .git/rebase-apply)
|
|
2326
|
+
const rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => '');
|
|
2327
|
+
const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => '');
|
|
2328
|
+
const rebaseMergeExists = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false;
|
|
2329
|
+
const rebaseApplyExists = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false;
|
|
2330
|
+
|
|
2331
|
+
if (rebaseMergeExists || rebaseApplyExists) {
|
|
2332
|
+
const rebasePath = rebaseMergeExists ? rebaseMergePath : rebaseApplyPath;
|
|
2333
|
+
const headName = await fsp.readFile(path.join(rebasePath, 'head-name'), 'utf8').catch(() => '');
|
|
2334
|
+
const onto = await fsp.readFile(path.join(rebasePath, 'onto'), 'utf8').catch(() => '');
|
|
2335
|
+
|
|
2336
|
+
const headNameTrimmed = headName.trim().replace('refs/heads/', '');
|
|
2337
|
+
const ontoTrimmed = onto.trim().slice(0, 7);
|
|
2338
|
+
|
|
2339
|
+
// Only set rebaseInProgress if we have valid data
|
|
2340
|
+
if (headNameTrimmed || ontoTrimmed) {
|
|
2341
|
+
rebaseInProgress = {
|
|
2342
|
+
headName: headNameTrimmed,
|
|
2343
|
+
onto: ontoTrimmed,
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
} catch {
|
|
2348
|
+
// ignore
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
return {
|
|
2352
|
+
current: status.current,
|
|
2353
|
+
tracking,
|
|
2354
|
+
ahead,
|
|
2355
|
+
behind,
|
|
2356
|
+
upstreamComparison,
|
|
2357
|
+
files: status.files.map((f) => ({
|
|
2358
|
+
path: f.path,
|
|
2359
|
+
index: f.index,
|
|
2360
|
+
working_dir: f.working_dir,
|
|
2361
|
+
})),
|
|
2362
|
+
isClean: status.isClean(),
|
|
2363
|
+
diffStats: lightMode ? undefined : diffStats,
|
|
2364
|
+
mergeInProgress,
|
|
2365
|
+
rebaseInProgress,
|
|
2366
|
+
};
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
if (isNotGitRepositoryError(error) || isMissingDirectoryError(error)) {
|
|
2369
|
+
// Re-throw a plain Error so route/session callers can match reliably and
|
|
2370
|
+
// continue enumerating other projects instead of treating GitError as 500.
|
|
2371
|
+
throw new Error('fatal: not a git repository (or any of the parent directories): .git');
|
|
2372
|
+
}
|
|
2373
|
+
console.error('Failed to get Git status:', error);
|
|
2374
|
+
throw error;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
|
|
2379
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
2380
|
+
|
|
2381
|
+
try {
|
|
2382
|
+
const args = ['diff', '--no-color'];
|
|
2383
|
+
const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
|
|
2384
|
+
|
|
2385
|
+
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
|
2386
|
+
args.push(`-U${Math.max(0, contextLines)}`);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
if (staged) {
|
|
2390
|
+
args.push('--cached');
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
if (fileContext) {
|
|
2394
|
+
args.push('--', fileContext.repoPath);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
const diff = await git.raw(args);
|
|
2398
|
+
if (diff && diff.trim().length > 0) {
|
|
2399
|
+
return diff;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
if (staged) {
|
|
2403
|
+
return diff;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
if (!fileContext) {
|
|
2407
|
+
return diff;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
try {
|
|
2411
|
+
await git.raw(['ls-files', '--error-unmatch', '--', fileContext.repoPath]);
|
|
2412
|
+
return diff;
|
|
2413
|
+
} catch {
|
|
2414
|
+
if (fileContext.isSymbolicLink) {
|
|
2415
|
+
const target = await fsp.readlink(fileContext.absolutePath);
|
|
2416
|
+
return [
|
|
2417
|
+
`diff --git a/${fileContext.repoPath} b/${fileContext.repoPath}`,
|
|
2418
|
+
'new file mode 120000',
|
|
2419
|
+
'--- /dev/null',
|
|
2420
|
+
`+++ b/${fileContext.repoPath}`,
|
|
2421
|
+
'@@ -0,0 +1 @@',
|
|
2422
|
+
`+${target}`,
|
|
2423
|
+
'\',
|
|
2424
|
+
'',
|
|
2425
|
+
].join('\n');
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const noIndexArgs = ['diff', '--no-color'];
|
|
2429
|
+
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
|
2430
|
+
noIndexArgs.push(`-U${Math.max(0, contextLines)}`);
|
|
2431
|
+
}
|
|
2432
|
+
noIndexArgs.push('--no-index', '--', '/dev/null', fileContext.repoPath);
|
|
2433
|
+
try {
|
|
2434
|
+
const noIndexDiff = await git.raw(noIndexArgs);
|
|
2435
|
+
return noIndexDiff;
|
|
2436
|
+
} catch (noIndexError) {
|
|
2437
|
+
// git diff --no-index returns exit code 1 when differences exist (not a real error)
|
|
2438
|
+
if (noIndexError.exitCode === 1 && noIndexError.message) {
|
|
2439
|
+
return noIndexError.message;
|
|
2440
|
+
}
|
|
2441
|
+
throw noIndexError;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
} catch (error) {
|
|
2445
|
+
console.error('Failed to get Git diff:', error);
|
|
2446
|
+
throw error;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Individual untracked file paths, honoring ignore rules.
|
|
2452
|
+
*
|
|
2453
|
+
* Deliberately not `--directory`: collapsed directory entries end in a slash
|
|
2454
|
+
* and are not valid inputs to the per-file diff helpers, so a caller would
|
|
2455
|
+
* silently lose every file inside a new directory. Listing files costs more
|
|
2456
|
+
* entries but each one is usable.
|
|
2457
|
+
*
|
|
2458
|
+
* Callers that only need this list should not pay for `getStatus`, which also
|
|
2459
|
+
* computes ahead/behind, diff stats, and merge state — an order of magnitude
|
|
2460
|
+
* more work for an answer they throw away.
|
|
2461
|
+
*/
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* Diffs for untracked files, produced against an empty tree.
|
|
2465
|
+
*
|
|
2466
|
+
* `getDiff` re-resolves the repository context on every call, which costs an
|
|
2467
|
+
* extra `rev-parse` per file; a walkthrough of a branch with thirty new files
|
|
2468
|
+
* pays that thirty times. This resolves once and reuses it, with a bounded pool
|
|
2469
|
+
* so a repository full of new files cannot flood the process table.
|
|
2470
|
+
*
|
|
2471
|
+
* Returns one entry per input path, in order; unreadable paths yield `''`
|
|
2472
|
+
* rather than failing the batch.
|
|
2473
|
+
*/
|
|
2474
|
+
|
|
2475
|
+
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
|
2476
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
2477
|
+
const baseRef = typeof base === 'string' ? base.trim() : '';
|
|
2478
|
+
const headRef = typeof head === 'string' ? head.trim() : '';
|
|
2479
|
+
if (!baseRef || !headRef) {
|
|
2480
|
+
throw new Error('base and head are required');
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// Prefer remote-tracking base ref so merged commits don't reappear
|
|
2484
|
+
// when local base branch is stale (common when user stays on feature branch).
|
|
2485
|
+
let resolvedBase = baseRef;
|
|
2486
|
+
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
|
2487
|
+
try {
|
|
2488
|
+
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
|
2489
|
+
if (verified && verified.trim()) {
|
|
2490
|
+
resolvedBase = `origin/${baseRef}`;
|
|
2491
|
+
}
|
|
2492
|
+
} catch {
|
|
2493
|
+
// ignore
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// Not every repository has an `origin`. When the base names a branch that
|
|
2497
|
+
// exists only on another remote, a bare name does not resolve — git looks in
|
|
2498
|
+
// refs/heads, not across remotes — and the diff fails with "ambiguous
|
|
2499
|
+
// argument". Fall back to whichever remote actually carries it.
|
|
2500
|
+
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
|
|
2501
|
+
const resolvesLocally = await git
|
|
2502
|
+
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
|
|
2503
|
+
.then((value) => Boolean(String(value || '').trim()))
|
|
2504
|
+
.catch(() => false);
|
|
2505
|
+
|
|
2506
|
+
if (!resolvesLocally) {
|
|
2507
|
+
const remoteMatch = await git
|
|
2508
|
+
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
|
|
2509
|
+
.then((value) => String(value || '').trim())
|
|
2510
|
+
.catch(() => '');
|
|
2511
|
+
if (remoteMatch) {
|
|
2512
|
+
resolvedBase = remoteMatch;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
const args = ['diff', '--no-color'];
|
|
2518
|
+
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
|
2519
|
+
args.push(`-U${Math.max(0, contextLines)}`);
|
|
2520
|
+
}
|
|
2521
|
+
args.push(`${resolvedBase}...${headRef}`);
|
|
2522
|
+
if (filePath) {
|
|
2523
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
2524
|
+
args.push('--', fileContext.repoPath);
|
|
2525
|
+
}
|
|
2526
|
+
const diff = await git.raw(args);
|
|
2527
|
+
return diff;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
|
|
2531
|
+
|
|
2532
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
2533
|
+
|
|
2534
|
+
function isImageFile(filePath) {
|
|
2535
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
2536
|
+
return IMAGE_EXTENSIONS.includes(ext || '');
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
function getImageMimeType(filePath) {
|
|
2540
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
2541
|
+
const mimeMap = {
|
|
2542
|
+
'png': 'image/png',
|
|
2543
|
+
'jpg': 'image/jpeg',
|
|
2544
|
+
'jpeg': 'image/jpeg',
|
|
2545
|
+
'gif': 'image/gif',
|
|
2546
|
+
'svg': 'image/svg+xml',
|
|
2547
|
+
'webp': 'image/webp',
|
|
2548
|
+
'ico': 'image/x-icon',
|
|
2549
|
+
'bmp': 'image/bmp',
|
|
2550
|
+
'avif': 'image/avif',
|
|
2551
|
+
};
|
|
2552
|
+
return mimeMap[ext] || 'application/octet-stream';
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
const parseIsBinaryFromNumstat = (raw) => {
|
|
2556
|
+
const text = String(raw || '').trim();
|
|
2557
|
+
if (!text) {
|
|
2558
|
+
return false;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// Expected format: <added>\t<deleted>\t<path>
|
|
2562
|
+
const firstLine = text.split('\n').map((line) => line.trim()).find(Boolean) || '';
|
|
2563
|
+
const [added, deleted] = firstLine.split('\t');
|
|
2564
|
+
return added === '-' || deleted === '-';
|
|
2565
|
+
};
|
|
2566
|
+
|
|
2567
|
+
const extractGitStatusPath = (status, pathPart) => {
|
|
2568
|
+
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
|
2569
|
+
return pathPart.split('\t').pop() || pathPart;
|
|
2570
|
+
}
|
|
2571
|
+
return pathPart;
|
|
2572
|
+
};
|
|
2573
|
+
|
|
2574
|
+
const extractGitNumstatDestinationPath = (filePath) => {
|
|
2575
|
+
if (!filePath.includes(' => ')) {
|
|
2576
|
+
return filePath;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
|
|
2580
|
+
if (braceMatch) {
|
|
2581
|
+
const [, prefix, , destination, suffix] = braceMatch;
|
|
2582
|
+
return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
return filePath.split(' => ').pop()?.trim() || filePath;
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
const looksBinaryBySniff = async (absolutePath) => {
|
|
2589
|
+
try {
|
|
2590
|
+
const handle = await fsp.open(absolutePath, 'r');
|
|
2591
|
+
try {
|
|
2592
|
+
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
2593
|
+
const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0);
|
|
2594
|
+
if (bytesRead <= 0) {
|
|
2595
|
+
return false;
|
|
2596
|
+
}
|
|
2597
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
2598
|
+
} finally {
|
|
2599
|
+
await handle.close();
|
|
2600
|
+
}
|
|
2601
|
+
} catch {
|
|
2602
|
+
return false;
|
|
2603
|
+
}
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
const isBinaryDiff = async (directoryPath, filePath, staged) => {
|
|
2607
|
+
// Fast path: ask git for numstat. For binary, it returns "-\t-\t<path>".
|
|
2608
|
+
const args = ['diff', '--numstat'];
|
|
2609
|
+
if (staged) {
|
|
2610
|
+
args.push('--cached');
|
|
2611
|
+
}
|
|
2612
|
+
args.push('--', filePath);
|
|
2613
|
+
|
|
2614
|
+
const result = await runGitCommand(directoryPath, args);
|
|
2615
|
+
if (parseIsBinaryFromNumstat(result.stdout)) {
|
|
2616
|
+
return true;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// Fallback for untracked files (diff output is empty): use --no-index against /dev/null
|
|
2620
|
+
if (!staged) {
|
|
2621
|
+
const tracked = await runGitCommand(directoryPath, ['ls-files', '--error-unmatch', '--', filePath]).then((r) => r.success);
|
|
2622
|
+
if (!tracked) {
|
|
2623
|
+
const noIndex = await runGitCommand(directoryPath, ['diff', '--no-index', '--numstat', '--', '/dev/null', filePath]);
|
|
2624
|
+
if (parseIsBinaryFromNumstat(noIndex.stdout) || parseIsBinaryFromNumstat(noIndex.stderr) || parseIsBinaryFromNumstat(noIndex.message)) {
|
|
2625
|
+
return true;
|
|
2626
|
+
}
|
|
2627
|
+
const text = `${noIndex.stdout || ''}\n${noIndex.stderr || ''}\n${noIndex.message || ''}`.toLowerCase();
|
|
2628
|
+
if (text.includes('binary files') || text.includes('git binary patch')) {
|
|
2629
|
+
return true;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
return false;
|
|
2635
|
+
};
|
|
2636
|
+
|
|
2637
|
+
export async function getFileDiff(directory, { path: filePath, staged = false } = {}) {
|
|
2638
|
+
if (!directory || !filePath) {
|
|
2639
|
+
throw new Error('directory and path are required for getFileDiff');
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
2643
|
+
const isImage = isImageFile(filePath);
|
|
2644
|
+
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
|
2645
|
+
const { absolutePath, repoPath, isSymbolicLink } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
2646
|
+
|
|
2647
|
+
if (!isImage && !isSymbolicLink) {
|
|
2648
|
+
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
|
|
2649
|
+
const isBinary = isBinaryBySniff || (await isBinaryDiff(repoRoot, repoPath, staged));
|
|
2650
|
+
if (isBinary) {
|
|
2651
|
+
return {
|
|
2652
|
+
original: '',
|
|
2653
|
+
modified: '',
|
|
2654
|
+
path: filePath,
|
|
2655
|
+
isBinary: true,
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
let original = '';
|
|
2661
|
+
try {
|
|
2662
|
+
if (isImage) {
|
|
2663
|
+
// For images, use git show with raw output and convert to base64
|
|
2664
|
+
try {
|
|
2665
|
+
const { stdout } = await execFileAsync(getGitBinary(), ['show', `HEAD:${repoPath}`], {
|
|
2666
|
+
cwd: repoRoot,
|
|
2667
|
+
encoding: 'buffer',
|
|
2668
|
+
windowsHide: true,
|
|
2669
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB max
|
|
2670
|
+
});
|
|
2671
|
+
if (stdout && stdout.length > 0) {
|
|
2672
|
+
original = `data:${mimeType};base64,${stdout.toString('base64')}`;
|
|
2673
|
+
}
|
|
2674
|
+
} catch {
|
|
2675
|
+
original = '';
|
|
2676
|
+
}
|
|
2677
|
+
} else {
|
|
2678
|
+
original = await git.show([`HEAD:${repoPath}`]);
|
|
2679
|
+
}
|
|
2680
|
+
} catch {
|
|
2681
|
+
original = '';
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
let modified = '';
|
|
2685
|
+
try {
|
|
2686
|
+
if (staged) {
|
|
2687
|
+
if (isImage) {
|
|
2688
|
+
const { stdout } = await execFileAsync(getGitBinary(), ['show', `:${repoPath}`], {
|
|
2689
|
+
cwd: repoRoot,
|
|
2690
|
+
encoding: 'buffer',
|
|
2691
|
+
windowsHide: true,
|
|
2692
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
2693
|
+
});
|
|
2694
|
+
if (stdout && stdout.length > 0) {
|
|
2695
|
+
modified = `data:${mimeType};base64,${stdout.toString('base64')}`;
|
|
2696
|
+
}
|
|
2697
|
+
} else {
|
|
2698
|
+
modified = await git.show([`:${repoPath}`]);
|
|
2699
|
+
}
|
|
2700
|
+
} else {
|
|
2701
|
+
if (isSymbolicLink) {
|
|
2702
|
+
modified = await fsp.readlink(absolutePath);
|
|
2703
|
+
} else {
|
|
2704
|
+
const stat = await fsp.stat(absolutePath);
|
|
2705
|
+
if (!stat.isFile()) {
|
|
2706
|
+
return {
|
|
2707
|
+
original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original,
|
|
2708
|
+
modified: '',
|
|
2709
|
+
path: filePath,
|
|
2710
|
+
isBinary: false,
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
if (isImage) {
|
|
2714
|
+
// For images, read as binary and convert to data URL
|
|
2715
|
+
const buffer = await fsp.readFile(absolutePath);
|
|
2716
|
+
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
2717
|
+
} else {
|
|
2718
|
+
modified = await fsp.readFile(absolutePath, 'utf8');
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
} catch (error) {
|
|
2723
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
2724
|
+
modified = '';
|
|
2725
|
+
} else {
|
|
2726
|
+
console.error('Failed to read modified file contents for diff:', error);
|
|
2727
|
+
throw error;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
return {
|
|
2732
|
+
original: typeof original === 'string' ? original.replace(/\r\n/g, '\n') : original,
|
|
2733
|
+
modified: typeof modified === 'string' ? modified.replace(/\r\n/g, '\n') : modified,
|
|
2734
|
+
path: filePath,
|
|
2735
|
+
isBinary: false,
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
export async function revertFile(directory, filePath, options = {}) {
|
|
2740
|
+
return withGitIndexMutationQueue(directory, async () => {
|
|
2741
|
+
const scope = options?.scope === 'working' ? 'working' : 'all';
|
|
2742
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
2743
|
+
const directoryGit = await createGit(directoryPath);
|
|
2744
|
+
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
|
2745
|
+
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
2746
|
+
const git = await createGit(repoRoot);
|
|
2747
|
+
|
|
2748
|
+
const isTracked = await git
|
|
2749
|
+
.raw(['ls-files', '--error-unmatch', '--', repoPath])
|
|
2750
|
+
.then(() => true)
|
|
2751
|
+
.catch(() => false);
|
|
2752
|
+
|
|
2753
|
+
if (!isTracked) {
|
|
2754
|
+
try {
|
|
2755
|
+
await git.raw(['clean', '-f', '-d', '--', repoPath]);
|
|
2756
|
+
return;
|
|
2757
|
+
} catch (cleanError) {
|
|
2758
|
+
try {
|
|
2759
|
+
await fsp.rm(absolutePath, { recursive: true, force: true });
|
|
2760
|
+
return;
|
|
2761
|
+
} catch (fsError) {
|
|
2762
|
+
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
console.error('Failed to remove untracked file during revert:', fsError);
|
|
2766
|
+
throw fsError;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
if (scope === 'all') {
|
|
2772
|
+
try {
|
|
2773
|
+
await git.raw(['restore', '--staged', '--', repoPath]);
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
try {
|
|
2780
|
+
await git.raw(['restore', '--', repoPath]);
|
|
2781
|
+
} catch (error) {
|
|
2782
|
+
try {
|
|
2783
|
+
await git.raw(['checkout', '--', repoPath]);
|
|
2784
|
+
} catch (fallbackError) {
|
|
2785
|
+
console.error('Failed to revert git file:', fallbackError);
|
|
2786
|
+
throw fallbackError;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
const HUNK_ACTION_FLAGS = {
|
|
2793
|
+
stage: ['--cached'],
|
|
2794
|
+
unstage: ['--cached', '--reverse'],
|
|
2795
|
+
discard: ['--reverse'],
|
|
2796
|
+
};
|
|
2797
|
+
|
|
2798
|
+
const parsePatchPathToken = (line) => {
|
|
2799
|
+
const value = String(line || '').replace(/^(?:-{3}|\+{3})\s+/, '');
|
|
2800
|
+
if (!value || value === '/dev/null') {
|
|
2801
|
+
return null;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
if (value.startsWith('"')) {
|
|
2805
|
+
let token = '"';
|
|
2806
|
+
let escaped = false;
|
|
2807
|
+
for (let index = 1; index < value.length; index += 1) {
|
|
2808
|
+
const char = value[index];
|
|
2809
|
+
token += char;
|
|
2810
|
+
if (escaped) {
|
|
2811
|
+
escaped = false;
|
|
2812
|
+
} else if (char === '\\') {
|
|
2813
|
+
escaped = true;
|
|
2814
|
+
} else if (char === '"') {
|
|
2815
|
+
break;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
try {
|
|
2820
|
+
return JSON.parse(token);
|
|
2821
|
+
} catch {
|
|
2822
|
+
return token.slice(1, token.endsWith('"') ? -1 : undefined);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
return value.split('\t', 1)[0] || null;
|
|
2827
|
+
};
|
|
2828
|
+
|
|
2829
|
+
const normalizePatchTargetPath = (value) => {
|
|
2830
|
+
if (!value || value === '/dev/null') {
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
return value.replace(/^[ab]\//, '');
|
|
2834
|
+
};
|
|
2835
|
+
|
|
2836
|
+
const extractPatchTargetPath = (patch) => {
|
|
2837
|
+
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
|
2838
|
+
const realTargets = matches
|
|
2839
|
+
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0])))
|
|
2840
|
+
.filter(Boolean);
|
|
2841
|
+
return realTargets[0] || null;
|
|
2842
|
+
};
|
|
2843
|
+
|
|
2844
|
+
const writeTempPatchFile = async (patch) => {
|
|
2845
|
+
const tmpDir = os.tmpdir();
|
|
2846
|
+
const tmpPath = path.join(tmpDir, `pichamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
|
2847
|
+
await fsp.writeFile(tmpPath, patch, 'utf8');
|
|
2848
|
+
return tmpPath;
|
|
2849
|
+
};
|
|
2850
|
+
|
|
2851
|
+
export async function applyHunk(directory, filePath, options = {}) {
|
|
2852
|
+
const action = options?.action;
|
|
2853
|
+
if (!action || !HUNK_ACTION_FLAGS[action]) {
|
|
2854
|
+
throw new Error('Invalid hunk action');
|
|
2855
|
+
}
|
|
2856
|
+
const patch = typeof options?.patch === 'string' ? options.patch : '';
|
|
2857
|
+
if (!patch.trim()) {
|
|
2858
|
+
throw new Error('patch is required to apply a hunk');
|
|
2859
|
+
}
|
|
2860
|
+
if (!/^@@\s/m.test(patch)) {
|
|
2861
|
+
throw new Error('patch does not contain a hunk header');
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
return withGitIndexMutationQueue(directory, async () => {
|
|
2865
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
2866
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
2867
|
+
validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]);
|
|
2868
|
+
|
|
2869
|
+
const targetPath = extractPatchTargetPath(patch);
|
|
2870
|
+
if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) {
|
|
2871
|
+
throw new Error('patch target path does not match the requested file');
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
const flags = HUNK_ACTION_FLAGS[action];
|
|
2875
|
+
let tmpPath = null;
|
|
2876
|
+
try {
|
|
2877
|
+
tmpPath = await writeTempPatchFile(patch);
|
|
2878
|
+
|
|
2879
|
+
try {
|
|
2880
|
+
await git.raw(['apply', ...flags, '--check', tmpPath]);
|
|
2881
|
+
} catch (checkError) {
|
|
2882
|
+
const text = parseGitErrorText(checkError);
|
|
2883
|
+
throw new Error(
|
|
2884
|
+
text
|
|
2885
|
+
? `Hunk no longer applies — refresh and try again.\n${text}`
|
|
2886
|
+
: 'Hunk no longer applies — refresh and try again.'
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
await git.raw(['apply', ...flags, tmpPath]);
|
|
2891
|
+
} finally {
|
|
2892
|
+
if (tmpPath) {
|
|
2893
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => {});
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
export async function pull(directory, options = {}) {
|
|
2900
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
2901
|
+
const pullOptions = options.rebase === true
|
|
2902
|
+
? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null }
|
|
2903
|
+
: options.options || {};
|
|
2904
|
+
|
|
2905
|
+
try {
|
|
2906
|
+
const remote = String(options.remote || '').trim();
|
|
2907
|
+
const requestedBranch = String(options.branch || '').trim();
|
|
2908
|
+
let branch = requestedBranch;
|
|
2909
|
+
|
|
2910
|
+
if (remote && !branch) {
|
|
2911
|
+
// simple-git only includes the remote when both remote and branch are provided.
|
|
2912
|
+
// Resolve the current branch so selecting a remote in the UI really runs `git pull <remote> <branch>`.
|
|
2913
|
+
const status = await git.status();
|
|
2914
|
+
branch = String(status.current || '').trim();
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
const result = await git.pull(
|
|
2918
|
+
remote || 'origin',
|
|
2919
|
+
branch || undefined,
|
|
2920
|
+
pullOptions
|
|
2921
|
+
);
|
|
2922
|
+
|
|
2923
|
+
return {
|
|
2924
|
+
success: true,
|
|
2925
|
+
summary: result.summary,
|
|
2926
|
+
files: result.files,
|
|
2927
|
+
insertions: result.insertions,
|
|
2928
|
+
deletions: result.deletions
|
|
2929
|
+
};
|
|
2930
|
+
} catch (error) {
|
|
2931
|
+
console.error('Failed to pull:', error);
|
|
2932
|
+
throw error;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
export async function listStashes(directory) {
|
|
2937
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
2938
|
+
const output = await git.raw(['stash', 'list', '--format=%gd%x1f%gs%x1f%cr%x1f%H']);
|
|
2939
|
+
return String(output || '')
|
|
2940
|
+
.split('\n')
|
|
2941
|
+
.map((line) => line.trim())
|
|
2942
|
+
.filter(Boolean)
|
|
2943
|
+
.map((line) => {
|
|
2944
|
+
const [ref = '', message = '', relativeTime = '', hash = ''] = line.split('\x1f');
|
|
2945
|
+
return { ref, message, relativeTime, hash };
|
|
2946
|
+
})
|
|
2947
|
+
.filter((entry) => entry.ref);
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
export async function countStashFiles(directory, refs = []) {
|
|
2951
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
2952
|
+
const uniqueRefs = Array.from(new Set((Array.isArray(refs) ? refs : []).map((ref) => String(ref || '').trim()).filter(Boolean)));
|
|
2953
|
+
const counts = {};
|
|
2954
|
+
const concurrency = 4;
|
|
2955
|
+
let cursor = 0;
|
|
2956
|
+
|
|
2957
|
+
const worker = async () => {
|
|
2958
|
+
while (cursor < uniqueRefs.length) {
|
|
2959
|
+
const ref = uniqueRefs[cursor++];
|
|
2960
|
+
if (!ref) continue;
|
|
2961
|
+
try {
|
|
2962
|
+
const names = await git.raw(['stash', 'show', '--name-only', ref]);
|
|
2963
|
+
counts[ref] = String(names || '').split('\n').map((line) => line.trim()).filter(Boolean).length;
|
|
2964
|
+
} catch {
|
|
2965
|
+
counts[ref] = 0;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, uniqueRefs.length) }, () => worker()));
|
|
2971
|
+
return counts;
|
|
2972
|
+
}
|
|
2973
|
+
export async function stashPush(directory, options = {}) {
|
|
2974
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
2975
|
+
const message = typeof options.message === 'string' && options.message.trim()
|
|
2976
|
+
? options.message.trim()
|
|
2977
|
+
: `PiChamber stash ${new Date().toISOString()}`;
|
|
2978
|
+
const output = await git.raw(['stash', 'push', '--include-untracked', '-m', message]);
|
|
2979
|
+
return {
|
|
2980
|
+
success: true,
|
|
2981
|
+
created: !/no local changes/i.test(String(output || '')),
|
|
2982
|
+
message,
|
|
2983
|
+
output: String(output || '').trim(),
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
export async function stashApply(directory, options = {}) {
|
|
2988
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
2989
|
+
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
|
|
2990
|
+
// Prefer --index so the staged/unstaged split captured in the stash is restored
|
|
2991
|
+
// faithfully. Fall back to a plain apply when the index can't be reinstated
|
|
2992
|
+
// cleanly (e.g. conflicts), which is the prior behavior.
|
|
2993
|
+
await git.raw(['stash', 'apply', '--index', ref]).catch(async () => {
|
|
2994
|
+
await git.raw(['stash', 'apply', ref]);
|
|
2995
|
+
});
|
|
2996
|
+
return { success: true, ref };
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
export async function stashDrop(directory, options = {}) {
|
|
3000
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3001
|
+
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
|
|
3002
|
+
await git.raw(['stash', 'drop', ref]);
|
|
3003
|
+
return { success: true, ref };
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
export async function stashPop(directory, options = {}) {
|
|
3007
|
+
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
|
|
3008
|
+
await stashApply(directory, { ref });
|
|
3009
|
+
await stashDrop(directory, { ref });
|
|
3010
|
+
return { success: true, ref };
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
export async function push(directory, options = {}) {
|
|
3014
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3015
|
+
|
|
3016
|
+
const describePushError = (error) => {
|
|
3017
|
+
const fromNestedGit = error?.git && typeof error.git === 'object'
|
|
3018
|
+
? [error.git.message, error.git.stderr, error.git.stdout]
|
|
3019
|
+
: [];
|
|
3020
|
+
const candidates = [
|
|
3021
|
+
error?.message,
|
|
3022
|
+
error?.stderr,
|
|
3023
|
+
error?.stdout,
|
|
3024
|
+
...fromNestedGit,
|
|
3025
|
+
]
|
|
3026
|
+
.map((value) => String(value || '').trim())
|
|
3027
|
+
.filter(Boolean);
|
|
3028
|
+
|
|
3029
|
+
return candidates[0] || 'Failed to push to remote';
|
|
3030
|
+
};
|
|
3031
|
+
|
|
3032
|
+
const buildUpstreamOptions = (raw) => {
|
|
3033
|
+
if (Array.isArray(raw)) {
|
|
3034
|
+
return raw.includes('--set-upstream') ? raw : [...raw, '--set-upstream'];
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
if (raw && typeof raw === 'object') {
|
|
3038
|
+
return { ...raw, '--set-upstream': null };
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
return ['--set-upstream'];
|
|
3042
|
+
};
|
|
3043
|
+
|
|
3044
|
+
const looksLikeMissingUpstream = (error) => {
|
|
3045
|
+
const message = String(error?.message || error?.stderr || '').toLowerCase();
|
|
3046
|
+
return (
|
|
3047
|
+
message.includes('has no upstream') ||
|
|
3048
|
+
message.includes('no upstream') ||
|
|
3049
|
+
message.includes('set-upstream') ||
|
|
3050
|
+
message.includes('set upstream') ||
|
|
3051
|
+
(message.includes('upstream') && message.includes('push') && message.includes('-u'))
|
|
3052
|
+
);
|
|
3053
|
+
};
|
|
3054
|
+
|
|
3055
|
+
const normalizePushResult = (result) => {
|
|
3056
|
+
return {
|
|
3057
|
+
success: true,
|
|
3058
|
+
pushed: result.pushed,
|
|
3059
|
+
repo: result.repo,
|
|
3060
|
+
ref: result.ref,
|
|
3061
|
+
};
|
|
3062
|
+
};
|
|
3063
|
+
|
|
3064
|
+
const remote = String(options.remote || '').trim();
|
|
3065
|
+
|
|
3066
|
+
if (!remote && !options.branch) {
|
|
3067
|
+
try {
|
|
3068
|
+
await git.push();
|
|
3069
|
+
return {
|
|
3070
|
+
success: true,
|
|
3071
|
+
pushed: [],
|
|
3072
|
+
repo: directory,
|
|
3073
|
+
ref: null,
|
|
3074
|
+
};
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
if (!looksLikeMissingUpstream(error)) {
|
|
3077
|
+
const message = describePushError(error);
|
|
3078
|
+
console.error('Failed to push:', error);
|
|
3079
|
+
throw new Error(message);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
try {
|
|
3083
|
+
const status = await git.status();
|
|
3084
|
+
const branch = status.current;
|
|
3085
|
+
const remotes = await git.getRemotes(true);
|
|
3086
|
+
const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name;
|
|
3087
|
+
if (!branch || !fallbackRemote) {
|
|
3088
|
+
const message = describePushError(error);
|
|
3089
|
+
throw new Error(message);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
const result = await git.push(fallbackRemote, branch, buildUpstreamOptions(options.options));
|
|
3093
|
+
return normalizePushResult(result);
|
|
3094
|
+
} catch (fallbackError) {
|
|
3095
|
+
const message = describePushError(fallbackError);
|
|
3096
|
+
console.error('Failed to push (including upstream fallback):', fallbackError);
|
|
3097
|
+
throw new Error(message);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
const remoteName = remote || 'origin';
|
|
3103
|
+
|
|
3104
|
+
// If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
|
|
3105
|
+
// When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
|
|
3106
|
+
if (!options.branch) {
|
|
3107
|
+
try {
|
|
3108
|
+
const status = await git.status();
|
|
3109
|
+
if (status.current && !status.tracking) {
|
|
3110
|
+
const result = await git.push(remoteName, status.current, buildUpstreamOptions(options.options));
|
|
3111
|
+
return normalizePushResult(result);
|
|
3112
|
+
}
|
|
3113
|
+
} catch (error) {
|
|
3114
|
+
// If we can't read status, fall back to the regular push path below.
|
|
3115
|
+
console.warn('Failed to read git status before push:', error);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
try {
|
|
3120
|
+
const result = await git.push(remoteName, options.branch, options.options || {});
|
|
3121
|
+
return normalizePushResult(result);
|
|
3122
|
+
} catch (error) {
|
|
3123
|
+
// Last-resort fallback: retry with upstream if the error suggests it's missing.
|
|
3124
|
+
if (!looksLikeMissingUpstream(error)) {
|
|
3125
|
+
const message = describePushError(error);
|
|
3126
|
+
console.error('Failed to push:', error);
|
|
3127
|
+
throw new Error(message);
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
try {
|
|
3131
|
+
const status = await git.status();
|
|
3132
|
+
const branch = options.branch || status.current;
|
|
3133
|
+
if (!branch) {
|
|
3134
|
+
console.error('Failed to push: missing branch name for upstream setup:', error);
|
|
3135
|
+
throw error;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
const result = await git.push(remoteName, branch, buildUpstreamOptions(options.options));
|
|
3139
|
+
return normalizePushResult(result);
|
|
3140
|
+
} catch (fallbackError) {
|
|
3141
|
+
const message = describePushError(fallbackError);
|
|
3142
|
+
console.error('Failed to push (including upstream fallback):', fallbackError);
|
|
3143
|
+
throw new Error(message);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
export async function deleteRemoteBranch(directory, options = {}) {
|
|
3149
|
+
const { branch, remote } = options;
|
|
3150
|
+
if (!branch) {
|
|
3151
|
+
throw new Error('branch is required to delete remote branch');
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3155
|
+
const targetBranch = branch.startsWith('refs/heads/')
|
|
3156
|
+
? branch.substring('refs/heads/'.length)
|
|
3157
|
+
: branch;
|
|
3158
|
+
const remoteName = remote || 'origin';
|
|
3159
|
+
|
|
3160
|
+
try {
|
|
3161
|
+
await git.push(remoteName, `:${targetBranch}`);
|
|
3162
|
+
return { success: true };
|
|
3163
|
+
} catch (error) {
|
|
3164
|
+
console.error('Failed to delete remote branch:', error);
|
|
3165
|
+
throw error;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
export async function fetch(directory, options = {}) {
|
|
3170
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3171
|
+
|
|
3172
|
+
try {
|
|
3173
|
+
const remote = String(options.remote || '').trim();
|
|
3174
|
+
const branch = String(options.branch || '').trim();
|
|
3175
|
+
const fetchOptions = options.options || {};
|
|
3176
|
+
|
|
3177
|
+
if (remote && !branch) {
|
|
3178
|
+
// simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch <remote>`.
|
|
3179
|
+
await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]);
|
|
3180
|
+
} else {
|
|
3181
|
+
await git.fetch(
|
|
3182
|
+
remote || 'origin',
|
|
3183
|
+
branch || undefined,
|
|
3184
|
+
fetchOptions
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
return { success: true };
|
|
3189
|
+
} catch (error) {
|
|
3190
|
+
console.error('Failed to fetch:', error);
|
|
3191
|
+
throw error;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
export async function stageFile(directory, filePath) {
|
|
3196
|
+
await stageFiles(directory, [filePath]);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
export async function stageFiles(directory, paths) {
|
|
3200
|
+
if (!directory) {
|
|
3201
|
+
throw new Error('directory and path are required for stageFile');
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
const filePaths = normalizeFilePathList(paths);
|
|
3205
|
+
if (filePaths.length === 0) {
|
|
3206
|
+
throw new Error('directory and path are required for stageFile');
|
|
3207
|
+
}
|
|
3208
|
+
validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
|
|
3209
|
+
|
|
3210
|
+
await withGitIndexMutationQueue(directory, async () => {
|
|
3211
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
3212
|
+
const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
|
|
3213
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
3214
|
+
return fileContext.repoPath;
|
|
3215
|
+
}))));
|
|
3216
|
+
validateRepositoryFilePaths(repoRoot, repoPaths);
|
|
3217
|
+
await git.raw(['add', '--', ...repoPaths]).catch(async (error) => {
|
|
3218
|
+
const gitErrorText = parseGitErrorText(error);
|
|
3219
|
+
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
|
|
3220
|
+
if (!isPathspecError) {
|
|
3221
|
+
throw error;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
// During rapid stage/unstage toggling the optimistic UI can request staging a
|
|
3225
|
+
// path that a prior queued mutation already staged (most visibly a deletion,
|
|
3226
|
+
// whose file is gone from the working tree). `git add` aborts the whole batch
|
|
3227
|
+
// on a single unmatched pathspec, so retry per-path and skip the ones already
|
|
3228
|
+
// in their target state rather than failing the entire "stage all".
|
|
3229
|
+
for (const repoPath of repoPaths) {
|
|
3230
|
+
await git.raw(['add', '--', repoPath]).catch((perPathError) => {
|
|
3231
|
+
const perPathText = parseGitErrorText(perPathError);
|
|
3232
|
+
const perPathIsPathspecError =
|
|
3233
|
+
perPathText.includes('pathspec') && perPathText.includes('did not match any files');
|
|
3234
|
+
if (!perPathIsPathspecError) {
|
|
3235
|
+
throw perPathError;
|
|
3236
|
+
}
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3239
|
+
});
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
export async function unstageFile(directory, filePath) {
|
|
3244
|
+
await unstageFiles(directory, [filePath]);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
export async function unstageFiles(directory, paths) {
|
|
3248
|
+
if (!directory) {
|
|
3249
|
+
throw new Error('directory and path are required for unstageFile');
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const filePaths = normalizeFilePathList(paths);
|
|
3253
|
+
if (filePaths.length === 0) {
|
|
3254
|
+
throw new Error('directory and path are required for unstageFile');
|
|
3255
|
+
}
|
|
3256
|
+
validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
|
|
3257
|
+
|
|
3258
|
+
await withGitIndexMutationQueue(directory, async () => {
|
|
3259
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
3260
|
+
const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
|
|
3261
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
3262
|
+
return fileContext.repoPath;
|
|
3263
|
+
}))));
|
|
3264
|
+
validateRepositoryFilePaths(repoRoot, repoPaths);
|
|
3265
|
+
await git.raw(['restore', '--staged', '--', ...repoPaths]).catch(async () => {
|
|
3266
|
+
await git.raw(['reset', 'HEAD', '--', ...repoPaths]);
|
|
3267
|
+
});
|
|
3268
|
+
});
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
export async function commit(directory, message, options = {}) {
|
|
3272
|
+
return withGitIndexMutationQueue(directory, async () => {
|
|
3273
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
3274
|
+
let temporarilyUnstagedFiles = [];
|
|
3275
|
+
|
|
3276
|
+
try {
|
|
3277
|
+
const requestedFiles = Array.isArray(options.files)
|
|
3278
|
+
? options.files
|
|
3279
|
+
.map((value) => String(value || '').trim())
|
|
3280
|
+
.filter(Boolean)
|
|
3281
|
+
: [];
|
|
3282
|
+
const requestedStageFiles = Array.isArray(options.stageFiles)
|
|
3283
|
+
? options.stageFiles
|
|
3284
|
+
.map((value) => String(value || '').trim())
|
|
3285
|
+
.filter(Boolean)
|
|
3286
|
+
: null;
|
|
3287
|
+
let filesToCommit = [];
|
|
3288
|
+
let commitFromIndexOnly = false;
|
|
3289
|
+
|
|
3290
|
+
if (options.addAll) {
|
|
3291
|
+
await git.add('.');
|
|
3292
|
+
} else if (requestedFiles.length > 0) {
|
|
3293
|
+
filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
|
|
3294
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
3295
|
+
return fileContext.repoPath;
|
|
3296
|
+
}))));
|
|
3297
|
+
|
|
3298
|
+
const stageFilesToCommit = requestedStageFiles
|
|
3299
|
+
? Array.from(new Set(await Promise.all(requestedStageFiles.map(async (filePath) => {
|
|
3300
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
3301
|
+
return fileContext.repoPath;
|
|
3302
|
+
}))))
|
|
3303
|
+
: null;
|
|
3304
|
+
|
|
3305
|
+
const status = await git.status();
|
|
3306
|
+
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
|
|
3307
|
+
filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
|
|
3308
|
+
|
|
3309
|
+
if (filesToCommit.length === 0) {
|
|
3310
|
+
throw new Error('No selected files are available to commit. Refresh git status and try again.');
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
if (requestedStageFiles) {
|
|
3314
|
+
commitFromIndexOnly = true;
|
|
3315
|
+
const selectedFileSet = new Set(filesToCommit);
|
|
3316
|
+
temporarilyUnstagedFiles = status.files
|
|
3317
|
+
.filter((file) => {
|
|
3318
|
+
const indexStatus = (file.index || '').trim();
|
|
3319
|
+
return indexStatus && indexStatus !== '?' && !selectedFileSet.has(file.path);
|
|
3320
|
+
})
|
|
3321
|
+
.map((file) => file.path);
|
|
3322
|
+
|
|
3323
|
+
if (temporarilyUnstagedFiles.length > 0) {
|
|
3324
|
+
await git.raw(['restore', '--staged', '--', ...temporarilyUnstagedFiles]);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
const filesNeedingAdd = requestedStageFiles
|
|
3329
|
+
? (stageFilesToCommit || []).filter((filePath) => fileStatusByPath.has(filePath))
|
|
3330
|
+
: filesToCommit.filter((filePath) => {
|
|
3331
|
+
const fileStatus = fileStatusByPath.get(filePath);
|
|
3332
|
+
if (!fileStatus) {
|
|
3333
|
+
return false;
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
|
|
3337
|
+
return !alreadyFullyStaged;
|
|
3338
|
+
});
|
|
3339
|
+
|
|
3340
|
+
if (filesNeedingAdd.length > 0) {
|
|
3341
|
+
await git.raw(['add', '--', ...filesNeedingAdd]);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
const commitArgs =
|
|
3346
|
+
!commitFromIndexOnly && !options.addAll && filesToCommit.length > 0
|
|
3347
|
+
? filesToCommit
|
|
3348
|
+
: undefined;
|
|
3349
|
+
|
|
3350
|
+
let result;
|
|
3351
|
+
try {
|
|
3352
|
+
result = await git.commit(message, commitArgs);
|
|
3353
|
+
} catch (error) {
|
|
3354
|
+
const gitErrorText = parseGitErrorText(error);
|
|
3355
|
+
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
|
|
3356
|
+
if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
|
|
3357
|
+
throw error;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// Fallback for deleted/stale selections: commit currently staged changes.
|
|
3361
|
+
result = await git.commit(message);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
if (temporarilyUnstagedFiles.length > 0) {
|
|
3365
|
+
await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
|
|
3366
|
+
console.error('Failed to restore temporarily unstaged files:', restoreError);
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
return {
|
|
3371
|
+
success: true,
|
|
3372
|
+
commit: result.commit,
|
|
3373
|
+
branch: result.branch,
|
|
3374
|
+
summary: result.summary
|
|
3375
|
+
};
|
|
3376
|
+
} catch (error) {
|
|
3377
|
+
if (temporarilyUnstagedFiles.length > 0) {
|
|
3378
|
+
await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
|
|
3379
|
+
console.error('Failed to restore temporarily unstaged files after commit failure:', restoreError);
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
console.error('Failed to commit:', error);
|
|
3383
|
+
throw error;
|
|
3384
|
+
}
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
export async function getBranches(directory) {
|
|
3389
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3390
|
+
|
|
3391
|
+
try {
|
|
3392
|
+
const result = await git.branch();
|
|
3393
|
+
|
|
3394
|
+
const allBranches = result.all;
|
|
3395
|
+
const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/'));
|
|
3396
|
+
const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches);
|
|
3397
|
+
const defaultBranches = await getRemoteDefaultBranches(git);
|
|
3398
|
+
|
|
3399
|
+
const filteredAll = [
|
|
3400
|
+
...allBranches.filter(branch => !branch.startsWith('remotes/')),
|
|
3401
|
+
...activeRemoteBranches
|
|
3402
|
+
];
|
|
3403
|
+
|
|
3404
|
+
return {
|
|
3405
|
+
all: filteredAll,
|
|
3406
|
+
current: result.current,
|
|
3407
|
+
branches: result.branches,
|
|
3408
|
+
defaultBranches,
|
|
3409
|
+
};
|
|
3410
|
+
} catch (error) {
|
|
3411
|
+
console.error('Failed to get branches:', error);
|
|
3412
|
+
throw error;
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
async function getRemoteDefaultBranches(git) {
|
|
3417
|
+
let defaults = {};
|
|
3418
|
+
|
|
3419
|
+
try {
|
|
3420
|
+
const refs = await git.raw([
|
|
3421
|
+
'for-each-ref',
|
|
3422
|
+
'--format=%(refname) %(symref)',
|
|
3423
|
+
'refs/remotes',
|
|
3424
|
+
]);
|
|
3425
|
+
defaults = Object.fromEntries(
|
|
3426
|
+
refs.trim().split('\n').flatMap((line) => {
|
|
3427
|
+
const [ref, symbolicRef] = line.split(' ');
|
|
3428
|
+
const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/);
|
|
3429
|
+
const prefix = match ? `refs/remotes/${match[1]}/` : '';
|
|
3430
|
+
return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix)
|
|
3431
|
+
? [[match[1], symbolicRef.slice(prefix.length)]]
|
|
3432
|
+
: [];
|
|
3433
|
+
})
|
|
3434
|
+
);
|
|
3435
|
+
} catch {
|
|
3436
|
+
defaults = {};
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// `remote/HEAD` is written by clone and by `git remote set-head`; a remote
|
|
3440
|
+
// added by hand may never have one. Without this the caller falls back to
|
|
3441
|
+
// guessing main/master/develop, which is exactly the guess this data exists
|
|
3442
|
+
// to replace — so ask the remote itself, but only for the remotes that are
|
|
3443
|
+
// actually missing an answer.
|
|
3444
|
+
try {
|
|
3445
|
+
const remotes = await git.getRemotes();
|
|
3446
|
+
const missing = remotes.filter((remote) => remote?.name && !defaults[remote.name]);
|
|
3447
|
+
if (missing.length === 0) return defaults;
|
|
3448
|
+
|
|
3449
|
+
const resolved = await Promise.all(missing.map(async (remote) => {
|
|
3450
|
+
try {
|
|
3451
|
+
const output = await git.raw(['ls-remote', '--symref', remote.name, 'HEAD']);
|
|
3452
|
+
const match = String(output || '').match(/^ref:\s+refs\/heads\/(.+?)\s+HEAD$/m);
|
|
3453
|
+
return match ? [remote.name, match[1]] : null;
|
|
3454
|
+
} catch {
|
|
3455
|
+
// Unreachable or refusing: no answer is better than a guessed one.
|
|
3456
|
+
return null;
|
|
3457
|
+
}
|
|
3458
|
+
}));
|
|
3459
|
+
|
|
3460
|
+
for (const entry of resolved) {
|
|
3461
|
+
if (entry) defaults[entry[0]] = entry[1];
|
|
3462
|
+
}
|
|
3463
|
+
} catch {
|
|
3464
|
+
// Remote list unavailable; the local symrefs are still valid.
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
return defaults;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
async function filterActiveRemoteBranches(git, remoteBranches) {
|
|
3471
|
+
try {
|
|
3472
|
+
const remotes = await git.getRemotes();
|
|
3473
|
+
const branchesByRemote = new Map();
|
|
3474
|
+
|
|
3475
|
+
// A remote that did not answer says nothing about its branches. Dropping
|
|
3476
|
+
// them would turn "we could not ask" into "these branches are gone", and
|
|
3477
|
+
// callers use this list to decide whether a base branch exists at all — so
|
|
3478
|
+
// offline would silently remove comparisons that work perfectly well
|
|
3479
|
+
// against the local remote-tracking refs.
|
|
3480
|
+
const unreachableRemotes = new Set();
|
|
3481
|
+
|
|
3482
|
+
await Promise.all(remotes.map(async (remote) => {
|
|
3483
|
+
try {
|
|
3484
|
+
const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
|
|
3485
|
+
const actualRemoteBranches = new Set();
|
|
3486
|
+
const lines = lsRemoteResult.trim().split('\n');
|
|
3487
|
+
for (const line of lines) {
|
|
3488
|
+
if (line.includes('\trefs/heads/')) {
|
|
3489
|
+
const branchName = line.split('\t')[1].replace('refs/heads/', '');
|
|
3490
|
+
actualRemoteBranches.add(branchName);
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
branchesByRemote.set(remote.name, actualRemoteBranches);
|
|
3494
|
+
} catch {
|
|
3495
|
+
unreachableRemotes.add(remote.name);
|
|
3496
|
+
}
|
|
3497
|
+
}));
|
|
3498
|
+
|
|
3499
|
+
return remoteBranches.filter(remoteBranch => {
|
|
3500
|
+
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
|
|
3501
|
+
if (!match) return false;
|
|
3502
|
+
const remoteName = remoteBranch.split('/')[1];
|
|
3503
|
+
const branchName = match[1];
|
|
3504
|
+
if (unreachableRemotes.has(remoteName)) return true;
|
|
3505
|
+
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
|
|
3506
|
+
});
|
|
3507
|
+
} catch (error) {
|
|
3508
|
+
console.warn('Failed to filter active remote branches, returning all:', error.message);
|
|
3509
|
+
return remoteBranches;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
export async function createBranch(directory, branchName, options = {}) {
|
|
3514
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3515
|
+
|
|
3516
|
+
try {
|
|
3517
|
+
await git.checkoutBranch(branchName, options.startPoint || 'HEAD');
|
|
3518
|
+
return { success: true, branch: branchName };
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
console.error('Failed to create branch:', error);
|
|
3521
|
+
throw error;
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
export async function checkoutBranch(directory, branchName) {
|
|
3526
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3527
|
+
|
|
3528
|
+
try {
|
|
3529
|
+
await git.checkout(branchName);
|
|
3530
|
+
return { success: true, branch: branchName };
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
console.error('Failed to checkout branch:', error);
|
|
3533
|
+
throw error;
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
export async function checkoutCommit(directory, hash) {
|
|
3538
|
+
if (!isValidCommitHash(hash)) {
|
|
3539
|
+
throw new Error('Invalid commit hash');
|
|
3540
|
+
}
|
|
3541
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3542
|
+
try {
|
|
3543
|
+
await git.checkout(hash);
|
|
3544
|
+
return { success: true };
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
console.error('Failed to checkout commit:', error);
|
|
3547
|
+
throw error;
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
export async function cherryPick(directory, hash) {
|
|
3552
|
+
if (!isValidCommitHash(hash)) {
|
|
3553
|
+
throw new Error('Invalid commit hash');
|
|
3554
|
+
}
|
|
3555
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3556
|
+
try {
|
|
3557
|
+
await git.raw(['cherry-pick', hash]);
|
|
3558
|
+
return { success: true, conflict: false };
|
|
3559
|
+
} catch (error) {
|
|
3560
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
3561
|
+
const isConflict =
|
|
3562
|
+
errorMessage.includes('conflict') ||
|
|
3563
|
+
errorMessage.includes('patch does not apply');
|
|
3564
|
+
|
|
3565
|
+
if (isConflict) {
|
|
3566
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
3567
|
+
return {
|
|
3568
|
+
success: false,
|
|
3569
|
+
conflict: true,
|
|
3570
|
+
conflictFiles: status.conflicted || [],
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
console.error('Failed to cherry-pick:', error);
|
|
3575
|
+
throw error;
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
export async function revertCommit(directory, hash) {
|
|
3580
|
+
if (!isValidCommitHash(hash)) {
|
|
3581
|
+
throw new Error('Invalid commit hash');
|
|
3582
|
+
}
|
|
3583
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3584
|
+
try {
|
|
3585
|
+
await git.raw(['revert', '--no-commit', hash]);
|
|
3586
|
+
return { success: true, conflict: false };
|
|
3587
|
+
} catch (error) {
|
|
3588
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
3589
|
+
const isConflict =
|
|
3590
|
+
errorMessage.includes('conflict') ||
|
|
3591
|
+
errorMessage.includes('revert failed');
|
|
3592
|
+
|
|
3593
|
+
if (isConflict) {
|
|
3594
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
3595
|
+
return {
|
|
3596
|
+
success: false,
|
|
3597
|
+
conflict: true,
|
|
3598
|
+
conflictFiles: status.conflicted || [],
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
console.error('Failed to revert commit:', error);
|
|
3603
|
+
throw error;
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
export async function resetToCommit(directory, hash, mode, force = false) {
|
|
3608
|
+
if (!isValidCommitHash(hash)) {
|
|
3609
|
+
throw new Error('Invalid commit hash');
|
|
3610
|
+
}
|
|
3611
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
3612
|
+
|
|
3613
|
+
if (mode === 'hard' && !force) {
|
|
3614
|
+
const status = await git.status();
|
|
3615
|
+
const isDirty = !status.isClean();
|
|
3616
|
+
if (isDirty) {
|
|
3617
|
+
throw new Error('Cannot hard reset: uncommitted changes in working tree. Stash or commit first, or use force.');
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
try {
|
|
3622
|
+
await git.raw(['reset', `--${mode}`, hash]);
|
|
3623
|
+
return { success: true };
|
|
3624
|
+
} catch (error) {
|
|
3625
|
+
console.error('Failed to reset to commit:', error);
|
|
3626
|
+
throw error;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
export async function getWorktrees(directory) {
|
|
3631
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
3632
|
+
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
|
3633
|
+
return [];
|
|
3634
|
+
}
|
|
3635
|
+
try {
|
|
3636
|
+
const directoryGit = await createGit(directoryPath);
|
|
3637
|
+
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
|
3638
|
+
const result = await runGitCommandOrThrow(
|
|
3639
|
+
repoRoot,
|
|
3640
|
+
['worktree', 'list', '--porcelain'],
|
|
3641
|
+
'Failed to list git worktrees'
|
|
3642
|
+
);
|
|
3643
|
+
return parseWorktreePorcelain(result.stdout).map((entry) => ({
|
|
3644
|
+
head: entry.head || '',
|
|
3645
|
+
name: path.basename(entry.worktree || ''),
|
|
3646
|
+
branch: entry.branch || '',
|
|
3647
|
+
path: entry.worktree,
|
|
3648
|
+
}));
|
|
3649
|
+
} catch (error) {
|
|
3650
|
+
console.warn('Failed to list worktrees, returning empty list:', error?.message || error);
|
|
3651
|
+
return [];
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
export async function validateWorktreeCreate(directory, input = {}) {
|
|
3656
|
+
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
|
3657
|
+
const errors = [];
|
|
3658
|
+
|
|
3659
|
+
try {
|
|
3660
|
+
const context = await resolveWorktreeProjectContext(directory);
|
|
3661
|
+
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
|
|
3662
|
+
const startRef = normalizeStartRef(input?.startRef);
|
|
3663
|
+
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
|
|
3664
|
+
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
|
|
3665
|
+
|
|
3666
|
+
let localBranch = '';
|
|
3667
|
+
let inferredUpstream = null;
|
|
3668
|
+
|
|
3669
|
+
if (mode === 'existing') {
|
|
3670
|
+
try {
|
|
3671
|
+
const requestedExistingBranch = String(input?.existingBranch || '').trim();
|
|
3672
|
+
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
|
|
3673
|
+
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
|
|
3674
|
+
const lsRemote = await runGitCommand(
|
|
3675
|
+
context.primaryWorktree,
|
|
3676
|
+
['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`]
|
|
3677
|
+
);
|
|
3678
|
+
if (!lsRemote.success) {
|
|
3679
|
+
throw new Error(`Unable to query remote ${ensureRemoteName}`);
|
|
3680
|
+
}
|
|
3681
|
+
if (!String(lsRemote.stdout || '').trim()) {
|
|
3682
|
+
throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`);
|
|
3683
|
+
}
|
|
3684
|
+
localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch);
|
|
3685
|
+
inferredUpstream = {
|
|
3686
|
+
remote: parsedExistingRemote.remote,
|
|
3687
|
+
branch: parsedExistingRemote.branch,
|
|
3688
|
+
};
|
|
3689
|
+
} else {
|
|
3690
|
+
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
|
|
3691
|
+
localBranch = resolved.localBranch || '';
|
|
3692
|
+
if (resolved.remoteRef) {
|
|
3693
|
+
inferredUpstream = {
|
|
3694
|
+
remote: resolved.remoteRef.remote,
|
|
3695
|
+
branch: resolved.remoteRef.branch,
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
} catch (error) {
|
|
3700
|
+
errors.push({
|
|
3701
|
+
code: 'branch_not_found',
|
|
3702
|
+
message: error instanceof Error ? error.message : 'Existing branch not found',
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
} else {
|
|
3706
|
+
if (preferredBranchName) {
|
|
3707
|
+
const exists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${preferredBranchName}`]);
|
|
3708
|
+
if (exists.success) {
|
|
3709
|
+
errors.push({
|
|
3710
|
+
code: 'branch_exists',
|
|
3711
|
+
message: `Branch already exists: ${preferredBranchName}`,
|
|
3712
|
+
});
|
|
3713
|
+
}
|
|
3714
|
+
localBranch = preferredBranchName;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
const parsedRemoteRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
|
|
3718
|
+
if (startRef && startRef !== 'HEAD') {
|
|
3719
|
+
if (parsedRemoteRef && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedRemoteRef.remote) {
|
|
3720
|
+
const remoteCheck = await checkRemoteBranchExists(
|
|
3721
|
+
context.primaryWorktree,
|
|
3722
|
+
parsedRemoteRef.remote,
|
|
3723
|
+
parsedRemoteRef.branch,
|
|
3724
|
+
ensureRemoteUrl
|
|
3725
|
+
);
|
|
3726
|
+
if (!remoteCheck.success) {
|
|
3727
|
+
errors.push({
|
|
3728
|
+
code: 'remote_unreachable',
|
|
3729
|
+
message: `Unable to query remote ${ensureRemoteName}`,
|
|
3730
|
+
});
|
|
3731
|
+
} else if (!remoteCheck.found) {
|
|
3732
|
+
errors.push({
|
|
3733
|
+
code: 'start_ref_not_found',
|
|
3734
|
+
message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
|
|
3735
|
+
});
|
|
3736
|
+
}
|
|
3737
|
+
} else if (parsedRemoteRef) {
|
|
3738
|
+
const remoteCheck = await checkRemoteBranchExists(
|
|
3739
|
+
context.primaryWorktree,
|
|
3740
|
+
parsedRemoteRef.remote,
|
|
3741
|
+
parsedRemoteRef.branch
|
|
3742
|
+
);
|
|
3743
|
+
if (!remoteCheck.success) {
|
|
3744
|
+
errors.push({
|
|
3745
|
+
code: 'remote_unreachable',
|
|
3746
|
+
message: `Unable to query remote ${parsedRemoteRef.remote}`,
|
|
3747
|
+
});
|
|
3748
|
+
} else if (!remoteCheck.found) {
|
|
3749
|
+
errors.push({
|
|
3750
|
+
code: 'start_ref_not_found',
|
|
3751
|
+
message: `Remote branch not found: ${parsedRemoteRef.remoteRef}`,
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
} else {
|
|
3755
|
+
const startRefExists = await runGitCommand(context.primaryWorktree, ['rev-parse', '--verify', '--quiet', startRef]);
|
|
3756
|
+
if (!startRefExists.success) {
|
|
3757
|
+
errors.push({
|
|
3758
|
+
code: 'start_ref_not_found',
|
|
3759
|
+
message: `Start ref not found: ${startRef}`,
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
if (parsedRemoteRef) {
|
|
3766
|
+
inferredUpstream = {
|
|
3767
|
+
remote: parsedRemoteRef.remote,
|
|
3768
|
+
branch: parsedRemoteRef.branch,
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
if (localBranch) {
|
|
3774
|
+
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
|
|
3775
|
+
if (inUse) {
|
|
3776
|
+
errors.push({
|
|
3777
|
+
code: 'branch_in_use',
|
|
3778
|
+
message: `Branch is already checked out in ${inUse.worktree}`,
|
|
3779
|
+
});
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
if ((ensureRemoteName && !ensureRemoteUrl) || (!ensureRemoteName && ensureRemoteUrl)) {
|
|
3784
|
+
errors.push({
|
|
3785
|
+
code: 'invalid_remote_config',
|
|
3786
|
+
message: 'Both ensureRemoteName and ensureRemoteUrl are required together',
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
const shouldSetUpstream = Boolean(input?.setUpstream);
|
|
3791
|
+
if (shouldSetUpstream) {
|
|
3792
|
+
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
|
3793
|
+
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
|
3794
|
+
|
|
3795
|
+
if (!upstreamRemote || !upstreamBranch) {
|
|
3796
|
+
errors.push({
|
|
3797
|
+
code: 'upstream_incomplete',
|
|
3798
|
+
message: 'upstreamRemote and upstreamBranch are required when setUpstream is true',
|
|
3799
|
+
});
|
|
3800
|
+
} else {
|
|
3801
|
+
const remoteExists = await runGitCommand(context.primaryWorktree, ['remote', 'get-url', upstreamRemote]);
|
|
3802
|
+
if (!remoteExists.success && (!ensureRemoteName || ensureRemoteName !== upstreamRemote)) {
|
|
3803
|
+
errors.push({
|
|
3804
|
+
code: 'remote_not_found',
|
|
3805
|
+
message: `Remote not found: ${upstreamRemote}`,
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
return {
|
|
3812
|
+
ok: errors.length === 0,
|
|
3813
|
+
errors,
|
|
3814
|
+
resolved: {
|
|
3815
|
+
mode,
|
|
3816
|
+
localBranch: localBranch || null,
|
|
3817
|
+
},
|
|
3818
|
+
};
|
|
3819
|
+
} catch (error) {
|
|
3820
|
+
return {
|
|
3821
|
+
ok: false,
|
|
3822
|
+
errors: [{
|
|
3823
|
+
code: 'validation_failed',
|
|
3824
|
+
message: error instanceof Error ? error.message : 'Failed to validate worktree creation',
|
|
3825
|
+
}],
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
const assertWorktreeCreatePreflight = async (directory, input = {}) => {
|
|
3831
|
+
const validation = await validateWorktreeCreate(directory, input);
|
|
3832
|
+
if (validation?.ok) {
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
const message = validation?.errors
|
|
3837
|
+
?.map((error) => error?.message)
|
|
3838
|
+
.filter(Boolean)
|
|
3839
|
+
.join('\n') || 'Failed to validate worktree creation';
|
|
3840
|
+
throw new Error(message);
|
|
3841
|
+
};
|
|
3842
|
+
|
|
3843
|
+
async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
|
3844
|
+
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
|
3845
|
+
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
|
|
3846
|
+
const startRef = normalizeStartRef(input?.startRef);
|
|
3847
|
+
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
|
|
3848
|
+
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
|
|
3849
|
+
|
|
3850
|
+
let localBranch = '';
|
|
3851
|
+
let inferredUpstream = null;
|
|
3852
|
+
const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
|
|
3853
|
+
|
|
3854
|
+
if (mode === 'existing') {
|
|
3855
|
+
const requestedExistingBranch = String(input?.existingBranch || '').trim();
|
|
3856
|
+
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
|
|
3857
|
+
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) {
|
|
3858
|
+
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
|
|
3859
|
+
await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch);
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
|
|
3863
|
+
localBranch = resolved.localBranch;
|
|
3864
|
+
|
|
3865
|
+
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
|
|
3866
|
+
if (inUse) {
|
|
3867
|
+
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
if (resolved.createLocalBranch) {
|
|
3871
|
+
worktreeAddArgs.push('-b', localBranch);
|
|
3872
|
+
}
|
|
3873
|
+
worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
|
|
3874
|
+
|
|
3875
|
+
if (resolved.remoteRef) {
|
|
3876
|
+
inferredUpstream = {
|
|
3877
|
+
remote: resolved.remoteRef.remote,
|
|
3878
|
+
branch: resolved.remoteRef.branch,
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3881
|
+
} else {
|
|
3882
|
+
localBranch = candidate.branch;
|
|
3883
|
+
if (!localBranch) {
|
|
3884
|
+
throw new Error('Failed to resolve branch name for new worktree');
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
const branchExists = await runGitCommand(context.primaryWorktree, ['show-ref', '--verify', '--quiet', `refs/heads/${localBranch}`]);
|
|
3888
|
+
if (branchExists.success) {
|
|
3889
|
+
throw new Error(`Branch already exists: ${localBranch}`);
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
|
|
3893
|
+
if (inUse) {
|
|
3894
|
+
throw new Error(`Branch is already checked out in ${inUse.worktree}`);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
worktreeAddArgs.push('-b', localBranch, candidate.directory);
|
|
3898
|
+
if (startRef && startRef !== 'HEAD') {
|
|
3899
|
+
worktreeAddArgs.push(startRef);
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
|
|
3903
|
+
if (parsedRemoteStartRef) {
|
|
3904
|
+
inferredUpstream = {
|
|
3905
|
+
remote: parsedRemoteStartRef.remote,
|
|
3906
|
+
branch: parsedRemoteStartRef.branch,
|
|
3907
|
+
};
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
if (ensureRemoteName && ensureRemoteUrl) {
|
|
3912
|
+
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
if (mode === 'new') {
|
|
3916
|
+
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
|
|
3917
|
+
if (parsedRemoteStartRef) {
|
|
3918
|
+
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
|
3923
|
+
|
|
3924
|
+
const shouldSetUpstream = Boolean(input?.setUpstream);
|
|
3925
|
+
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
|
3926
|
+
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
|
3927
|
+
|
|
3928
|
+
const bootstrapStatus = setWorktreeBootstrapState(
|
|
3929
|
+
candidate.directory,
|
|
3930
|
+
WORKTREE_BOOTSTRAP_PENDING,
|
|
3931
|
+
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
|
|
3932
|
+
);
|
|
3933
|
+
|
|
3934
|
+
queueWorktreeBootstrap({
|
|
3935
|
+
directory: candidate.directory,
|
|
3936
|
+
projectID: context.projectID,
|
|
3937
|
+
primaryWorktree: context.primaryWorktree,
|
|
3938
|
+
localBranch,
|
|
3939
|
+
setUpstream: shouldSetUpstream,
|
|
3940
|
+
upstreamRemote,
|
|
3941
|
+
upstreamBranch,
|
|
3942
|
+
ensureRemoteName,
|
|
3943
|
+
ensureRemoteUrl,
|
|
3944
|
+
startCommand: input?.startCommand,
|
|
3945
|
+
});
|
|
3946
|
+
|
|
3947
|
+
const headResult = await runGitCommand(candidate.directory, ['rev-parse', 'HEAD']);
|
|
3948
|
+
const head = String(headResult.stdout || '').trim();
|
|
3949
|
+
|
|
3950
|
+
return {
|
|
3951
|
+
head,
|
|
3952
|
+
name: candidate.name,
|
|
3953
|
+
branch: localBranch,
|
|
3954
|
+
path: candidate.directory,
|
|
3955
|
+
directoryCreated: true,
|
|
3956
|
+
bootstrapStatus,
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
export async function createWorktree(directory, input = {}) {
|
|
3961
|
+
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
|
3962
|
+
const context = await resolveWorktreeProjectContext(directory);
|
|
3963
|
+
|
|
3964
|
+
if (input?.returnAfterDirectoryCreated === true) {
|
|
3965
|
+
await assertWorktreeCreatePreflight(directory, input);
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
await fsp.mkdir(context.worktreeRoot, { recursive: true });
|
|
3969
|
+
|
|
3970
|
+
const preferredName = String(input?.worktreeName || input?.name || '').trim();
|
|
3971
|
+
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
|
|
3972
|
+
|
|
3973
|
+
const candidate = await resolveCandidateDirectory(
|
|
3974
|
+
context.worktreeRoot,
|
|
3975
|
+
preferredName,
|
|
3976
|
+
mode === 'new' && preferredBranchName ? preferredBranchName : '',
|
|
3977
|
+
context.primaryWorktree
|
|
3978
|
+
);
|
|
3979
|
+
|
|
3980
|
+
if (input?.returnAfterDirectoryCreated === true) {
|
|
3981
|
+
await fsp.mkdir(candidate.directory, { recursive: false });
|
|
3982
|
+
|
|
3983
|
+
const bootstrapStatus = setWorktreeBootstrapState(
|
|
3984
|
+
candidate.directory,
|
|
3985
|
+
WORKTREE_BOOTSTRAP_PENDING,
|
|
3986
|
+
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
|
|
3987
|
+
);
|
|
3988
|
+
const localBranch = mode === 'existing'
|
|
3989
|
+
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
|
|
3990
|
+
: candidate.branch;
|
|
3991
|
+
|
|
3992
|
+
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
|
|
3993
|
+
setWorktreeBootstrapState(
|
|
3994
|
+
candidate.directory,
|
|
3995
|
+
WORKTREE_BOOTSTRAP_FAILED,
|
|
3996
|
+
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
|
|
3997
|
+
error instanceof Error ? error.message : String(error)
|
|
3998
|
+
);
|
|
3999
|
+
await cleanupFailedFastWorktreeCreate(context, candidate);
|
|
4000
|
+
console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error));
|
|
4001
|
+
});
|
|
4002
|
+
trackWorktreeBootstrapTask(candidate.directory, task);
|
|
4003
|
+
|
|
4004
|
+
return {
|
|
4005
|
+
head: '',
|
|
4006
|
+
name: candidate.name,
|
|
4007
|
+
branch: localBranch,
|
|
4008
|
+
path: candidate.directory,
|
|
4009
|
+
directoryCreated: true,
|
|
4010
|
+
bootstrapStatus,
|
|
4011
|
+
};
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
return attachGitWorktreeToCandidate(context, candidate, input);
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
export async function getWorktreeBootstrapStatus(directory) {
|
|
4018
|
+
const key = toBootstrapStateKey(directory);
|
|
4019
|
+
if (!key) {
|
|
4020
|
+
throw new Error('Worktree directory is required');
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
const current = worktreeBootstrapState.get(key);
|
|
4024
|
+
if (current) {
|
|
4025
|
+
return current;
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
return createWorktreeBootstrapState(
|
|
4029
|
+
WORKTREE_BOOTSTRAP_READY,
|
|
4030
|
+
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
|
|
4031
|
+
);
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
export async function removeWorktree(directory, input = {}) {
|
|
4035
|
+
const targetDirectory = normalizeDirectoryPath(input?.directory);
|
|
4036
|
+
if (!targetDirectory) {
|
|
4037
|
+
throw new Error('Worktree directory is required');
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
await waitForActiveWorktreeBootstrap(targetDirectory);
|
|
4041
|
+
|
|
4042
|
+
const context = await resolveWorktreeProjectContext(directory);
|
|
4043
|
+
const deleteLocalBranch = input?.deleteLocalBranch === true;
|
|
4044
|
+
|
|
4045
|
+
const targetCanonical = await canonicalPath(targetDirectory);
|
|
4046
|
+
const primaryCanonical = await canonicalPath(context.primaryWorktree);
|
|
4047
|
+
if (targetCanonical === primaryCanonical) {
|
|
4048
|
+
throw new Error('Cannot remove the primary workspace');
|
|
4049
|
+
}
|
|
4050
|
+
const worktreeRootCanonical = await canonicalPath(context.worktreeRoot);
|
|
4051
|
+
|
|
4052
|
+
const entries = await listWorktreeEntries(context.primaryWorktree);
|
|
4053
|
+
const matchedEntry = await (async () => {
|
|
4054
|
+
for (const entry of entries) {
|
|
4055
|
+
if (!entry?.worktree) {
|
|
4056
|
+
continue;
|
|
4057
|
+
}
|
|
4058
|
+
const entryCanonical = await canonicalPath(entry.worktree);
|
|
4059
|
+
if (entryCanonical === targetCanonical) {
|
|
4060
|
+
return entry;
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
return null;
|
|
4064
|
+
})();
|
|
4065
|
+
|
|
4066
|
+
if (!matchedEntry?.worktree) {
|
|
4067
|
+
const isManagedOrphan = targetCanonical !== worktreeRootCanonical
|
|
4068
|
+
&& isInsideOrSameDirectory(worktreeRootCanonical, targetCanonical);
|
|
4069
|
+
|
|
4070
|
+
const targetExists = await checkPathExists(targetDirectory);
|
|
4071
|
+
if (targetExists && isManagedOrphan) {
|
|
4072
|
+
await fsp.rm(targetDirectory, { recursive: true, force: true });
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
clearWorktreeBootstrapState(targetDirectory);
|
|
4076
|
+
|
|
4077
|
+
return true;
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
await runGitCommandOrThrow(
|
|
4081
|
+
context.primaryWorktree,
|
|
4082
|
+
['worktree', 'remove', '--force', matchedEntry.worktree],
|
|
4083
|
+
'Failed to remove git worktree'
|
|
4084
|
+
);
|
|
4085
|
+
|
|
4086
|
+
if (deleteLocalBranch) {
|
|
4087
|
+
const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
|
|
4088
|
+
if (branchName) {
|
|
4089
|
+
await runGitCommandOrThrow(
|
|
4090
|
+
context.primaryWorktree,
|
|
4091
|
+
['branch', '-D', branchName],
|
|
4092
|
+
`Failed to delete local branch ${branchName}`
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
|
|
4097
|
+
clearWorktreeBootstrapState(matchedEntry.worktree);
|
|
4098
|
+
|
|
4099
|
+
return true;
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
export async function deleteBranch(directory, branch, options = {}) {
|
|
4103
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4104
|
+
|
|
4105
|
+
try {
|
|
4106
|
+
const branchName = branch.startsWith('refs/heads/')
|
|
4107
|
+
? branch.substring('refs/heads/'.length)
|
|
4108
|
+
: branch;
|
|
4109
|
+
const args = ['branch', options.force ? '-D' : '-d', branchName];
|
|
4110
|
+
await git.raw(args);
|
|
4111
|
+
return { success: true };
|
|
4112
|
+
} catch (error) {
|
|
4113
|
+
console.error('Failed to delete branch:', error);
|
|
4114
|
+
throw error;
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
/**
|
|
4119
|
+
* Resolve a log base ref using local-first semantics.
|
|
4120
|
+
*
|
|
4121
|
+
* - If `from` is falsy / whitespace → return undefined.
|
|
4122
|
+
* - If the local ref resolves → return it unchanged (caller's intent preserved).
|
|
4123
|
+
* - If the local ref is absent but `origin/<from>` exists → return `origin/<from>`
|
|
4124
|
+
* (common when the user has never checked out the base branch locally).
|
|
4125
|
+
* - If neither resolves → return `from` unchanged so git surfaces a meaningful error.
|
|
4126
|
+
*
|
|
4127
|
+
* @param {string | undefined} from - The raw `from` option value.
|
|
4128
|
+
* @param {(ref: string) => Promise<boolean>} checkRef - Returns true when the ref resolves.
|
|
4129
|
+
* @returns {Promise<string | undefined>}
|
|
4130
|
+
*/
|
|
4131
|
+
export async function resolveBaseRefForLog(from, checkRef) {
|
|
4132
|
+
const normalized = typeof from === 'string' ? from.trim() : undefined;
|
|
4133
|
+
if (!normalized) return undefined;
|
|
4134
|
+
|
|
4135
|
+
if (await checkRef(normalized)) return normalized;
|
|
4136
|
+
|
|
4137
|
+
const originRef = `refs/remotes/origin/${normalized}`;
|
|
4138
|
+
if (await checkRef(originRef)) return `origin/${normalized}`;
|
|
4139
|
+
|
|
4140
|
+
return normalized;
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
export async function getLog(directory, options = {}) {
|
|
4144
|
+
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
4145
|
+
|
|
4146
|
+
try {
|
|
4147
|
+
const maxCount = options.maxCount || 50;
|
|
4148
|
+
|
|
4149
|
+
if (options.all) {
|
|
4150
|
+
const logArgs = [
|
|
4151
|
+
'log',
|
|
4152
|
+
`--max-count=${maxCount}`,
|
|
4153
|
+
'--all',
|
|
4154
|
+
'--topo-order',
|
|
4155
|
+
'--date=iso',
|
|
4156
|
+
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D',
|
|
4157
|
+
'--shortstat',
|
|
4158
|
+
];
|
|
4159
|
+
|
|
4160
|
+
const rawLog = await git.raw(logArgs);
|
|
4161
|
+
const records = rawLog
|
|
4162
|
+
.split('\x1e')
|
|
4163
|
+
.map((e) => e.trim())
|
|
4164
|
+
.filter(Boolean);
|
|
4165
|
+
|
|
4166
|
+
const entries = [];
|
|
4167
|
+
for (const record of records) {
|
|
4168
|
+
const lines = record.split('\n').filter((l) => l.trim().length > 0);
|
|
4169
|
+
const header = lines.shift() || '';
|
|
4170
|
+
const [hash, parentsRaw, author_name, author_email, date, message, refsRaw] =
|
|
4171
|
+
header.split('\x1f');
|
|
4172
|
+
if (!hash) continue;
|
|
4173
|
+
|
|
4174
|
+
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
|
4175
|
+
const refs = refsRaw ? refsRaw.trim() : '';
|
|
4176
|
+
|
|
4177
|
+
let filesChanged = 0;
|
|
4178
|
+
let insertions = 0;
|
|
4179
|
+
let deletions = 0;
|
|
4180
|
+
for (const line of lines) {
|
|
4181
|
+
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
|
4182
|
+
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
|
4183
|
+
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
|
4184
|
+
if (filesMatch) filesChanged = parseInt(filesMatch[1], 10);
|
|
4185
|
+
if (insertMatch) insertions = parseInt(insertMatch[1], 10);
|
|
4186
|
+
if (deleteMatch) deletions = parseInt(deleteMatch[1], 10);
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
entries.push({
|
|
4190
|
+
hash,
|
|
4191
|
+
date: date || '',
|
|
4192
|
+
message: message || '',
|
|
4193
|
+
refs,
|
|
4194
|
+
body: '',
|
|
4195
|
+
author_name: author_name || '',
|
|
4196
|
+
author_email: author_email || '',
|
|
4197
|
+
filesChanged,
|
|
4198
|
+
insertions,
|
|
4199
|
+
deletions,
|
|
4200
|
+
parents,
|
|
4201
|
+
});
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
return { all: entries, latest: entries[0] || null, total: entries.length };
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
const filePath = options.file
|
|
4208
|
+
? (await resolveGitFileContext(directoryPath, directoryGit, options.file, repoRoot)).repoPath
|
|
4209
|
+
: undefined;
|
|
4210
|
+
|
|
4211
|
+
// Prefer the local ref; fall back to origin/<from> only when the local ref
|
|
4212
|
+
// cannot be resolved (e.g. user has never checked out the base branch).
|
|
4213
|
+
const checkRef = async (ref) => {
|
|
4214
|
+
try {
|
|
4215
|
+
const out = await git.raw(['rev-parse', '--verify', ref]);
|
|
4216
|
+
return Boolean(out && out.trim());
|
|
4217
|
+
} catch {
|
|
4218
|
+
return false;
|
|
4219
|
+
}
|
|
4220
|
+
};
|
|
4221
|
+
const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
|
|
4222
|
+
|
|
4223
|
+
const baseLog = await git.log({
|
|
4224
|
+
maxCount,
|
|
4225
|
+
from: resolvedFrom,
|
|
4226
|
+
to: options.to,
|
|
4227
|
+
file: filePath
|
|
4228
|
+
});
|
|
4229
|
+
|
|
4230
|
+
const logArgs = [
|
|
4231
|
+
'log',
|
|
4232
|
+
`--max-count=${maxCount}`,
|
|
4233
|
+
'--date=iso',
|
|
4234
|
+
'--pretty=format:%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s',
|
|
4235
|
+
'--shortstat'
|
|
4236
|
+
];
|
|
4237
|
+
|
|
4238
|
+
if (resolvedFrom && options.to) {
|
|
4239
|
+
logArgs.push(`${resolvedFrom}..${options.to}`);
|
|
4240
|
+
} else if (resolvedFrom) {
|
|
4241
|
+
logArgs.push(`${resolvedFrom}..HEAD`);
|
|
4242
|
+
} else if (options.to) {
|
|
4243
|
+
logArgs.push(options.to);
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
if (filePath) {
|
|
4247
|
+
logArgs.push('--', filePath);
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
const rawLog = await git.raw(logArgs);
|
|
4251
|
+
const records = rawLog
|
|
4252
|
+
.split('\x1e')
|
|
4253
|
+
.map((entry) => entry.trim())
|
|
4254
|
+
.filter(Boolean);
|
|
4255
|
+
|
|
4256
|
+
const statsMap = new Map();
|
|
4257
|
+
|
|
4258
|
+
records.forEach((record) => {
|
|
4259
|
+
const lines = record.split('\n').filter((line) => line.trim().length > 0);
|
|
4260
|
+
const header = lines.shift() || '';
|
|
4261
|
+
const [hash, parentsRaw] = header.split('\x1f');
|
|
4262
|
+
const parents = parentsRaw ? parentsRaw.trim().split(' ').filter(Boolean) : [];
|
|
4263
|
+
if (!hash) {
|
|
4264
|
+
return;
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
let filesChanged = 0;
|
|
4268
|
+
let insertions = 0;
|
|
4269
|
+
let deletions = 0;
|
|
4270
|
+
|
|
4271
|
+
lines.forEach((line) => {
|
|
4272
|
+
const filesMatch = line.match(/(\d+)\s+files?\s+changed/);
|
|
4273
|
+
const insertMatch = line.match(/(\d+)\s+insertions?\(\+\)/);
|
|
4274
|
+
const deleteMatch = line.match(/(\d+)\s+deletions?\(-\)/);
|
|
4275
|
+
|
|
4276
|
+
if (filesMatch) {
|
|
4277
|
+
filesChanged = parseInt(filesMatch[1], 10);
|
|
4278
|
+
}
|
|
4279
|
+
if (insertMatch) {
|
|
4280
|
+
insertions = parseInt(insertMatch[1], 10);
|
|
4281
|
+
}
|
|
4282
|
+
if (deleteMatch) {
|
|
4283
|
+
deletions = parseInt(deleteMatch[1], 10);
|
|
4284
|
+
}
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
statsMap.set(hash, { filesChanged, insertions, deletions, parents });
|
|
4288
|
+
});
|
|
4289
|
+
|
|
4290
|
+
const merged = baseLog.all.map((entry) => {
|
|
4291
|
+
const stats = statsMap.get(entry.hash) || { filesChanged: 0, insertions: 0, deletions: 0, parents: [] };
|
|
4292
|
+
return {
|
|
4293
|
+
hash: entry.hash,
|
|
4294
|
+
date: entry.date,
|
|
4295
|
+
message: entry.message,
|
|
4296
|
+
refs: entry.refs || '',
|
|
4297
|
+
body: entry.body || '',
|
|
4298
|
+
author_name: entry.author_name,
|
|
4299
|
+
author_email: entry.author_email,
|
|
4300
|
+
filesChanged: stats.filesChanged,
|
|
4301
|
+
insertions: stats.insertions,
|
|
4302
|
+
deletions: stats.deletions,
|
|
4303
|
+
parents: stats.parents || [],
|
|
4304
|
+
};
|
|
4305
|
+
});
|
|
4306
|
+
|
|
4307
|
+
return {
|
|
4308
|
+
all: merged,
|
|
4309
|
+
latest: merged[0] || null,
|
|
4310
|
+
total: baseLog.total
|
|
4311
|
+
};
|
|
4312
|
+
} catch (error) {
|
|
4313
|
+
console.error('Failed to get log:', error);
|
|
4314
|
+
throw error;
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
export async function isLinkedWorktree(directory) {
|
|
4319
|
+
const git = await createGit(directory);
|
|
4320
|
+
try {
|
|
4321
|
+
const [gitDir, gitCommonDir] = await Promise.all([
|
|
4322
|
+
git.raw(['rev-parse', '--git-dir']).then((output) => output.trim()),
|
|
4323
|
+
git.raw(['rev-parse', '--git-common-dir']).then((output) => output.trim())
|
|
4324
|
+
]);
|
|
4325
|
+
return gitDir !== gitCommonDir;
|
|
4326
|
+
} catch (error) {
|
|
4327
|
+
console.error('Failed to determine worktree type:', error);
|
|
4328
|
+
return false;
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
export async function validateWorktreeDirectory(directory, worktreeRoot) {
|
|
4333
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
4334
|
+
const rootPath = normalizeDirectoryPath(worktreeRoot);
|
|
4335
|
+
|
|
4336
|
+
if (!directoryPath || !rootPath) {
|
|
4337
|
+
return {
|
|
4338
|
+
valid: false,
|
|
4339
|
+
insideWorktreeRoot: false,
|
|
4340
|
+
resolvedWorktreeRoot: null,
|
|
4341
|
+
resolvedCwd: null,
|
|
4342
|
+
};
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
const isRepo = await isGitRepository(directoryPath);
|
|
4346
|
+
if (!isRepo) {
|
|
4347
|
+
return {
|
|
4348
|
+
valid: false,
|
|
4349
|
+
insideWorktreeRoot: false,
|
|
4350
|
+
resolvedWorktreeRoot: null,
|
|
4351
|
+
resolvedCwd: null,
|
|
4352
|
+
};
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
const resolvedCwd = await canonicalPath(directoryPath);
|
|
4356
|
+
const resolvedRoot = await canonicalPath(rootPath);
|
|
4357
|
+
|
|
4358
|
+
const inside = resolvedCwd.startsWith(resolvedRoot + path.sep) || resolvedCwd === resolvedRoot;
|
|
4359
|
+
|
|
4360
|
+
return {
|
|
4361
|
+
valid: true,
|
|
4362
|
+
insideWorktreeRoot: inside,
|
|
4363
|
+
resolvedWorktreeRoot: resolvedRoot,
|
|
4364
|
+
resolvedCwd,
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
export async function canonicalizeWorktreeState(directory) {
|
|
4369
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
4370
|
+
|
|
4371
|
+
if (!directoryPath) {
|
|
4372
|
+
return {
|
|
4373
|
+
worktreeRoot: null,
|
|
4374
|
+
cwd: null,
|
|
4375
|
+
branch: null,
|
|
4376
|
+
headState: 'detached',
|
|
4377
|
+
worktreeStatus: 'not-a-repo',
|
|
4378
|
+
legacy: false,
|
|
4379
|
+
degraded: false,
|
|
4380
|
+
attentionReason: null,
|
|
4381
|
+
};
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
const isRepo = await isGitRepository(directoryPath);
|
|
4385
|
+
if (!isRepo) {
|
|
4386
|
+
return {
|
|
4387
|
+
worktreeRoot: null,
|
|
4388
|
+
cwd: null,
|
|
4389
|
+
branch: null,
|
|
4390
|
+
headState: 'detached',
|
|
4391
|
+
worktreeStatus: 'not-a-repo',
|
|
4392
|
+
legacy: false,
|
|
4393
|
+
degraded: false,
|
|
4394
|
+
attentionReason: null,
|
|
4395
|
+
};
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4398
|
+
const cwd = await canonicalPath(directoryPath);
|
|
4399
|
+
const git = await createGit(directoryPath);
|
|
4400
|
+
const repoRoot = await resolveGitRepositoryRoot(directoryPath, git).catch(() => directoryPath);
|
|
4401
|
+
|
|
4402
|
+
let worktreeRoot = null;
|
|
4403
|
+
let worktreeStatus = 'ready';
|
|
4404
|
+
let headState = /** @type {'branch' | 'detached' | 'unborn'} */ ('branch');
|
|
4405
|
+
let branch = null;
|
|
4406
|
+
let attentionReason = /** @type {'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null} */ (null);
|
|
4407
|
+
|
|
4408
|
+
try {
|
|
4409
|
+
const context = await resolveWorktreeProjectContext(directoryPath);
|
|
4410
|
+
worktreeRoot = await canonicalPath(context.worktreeRoot);
|
|
4411
|
+
} catch {
|
|
4412
|
+
worktreeStatus = 'invalid';
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
try {
|
|
4416
|
+
const symbolicRef = await git.raw(['symbolic-ref', '-q', 'HEAD']).catch(() => '');
|
|
4417
|
+
if (symbolicRef.trim()) {
|
|
4418
|
+
headState = 'branch';
|
|
4419
|
+
branch = cleanBranchName(symbolicRef.trim());
|
|
4420
|
+
} else {
|
|
4421
|
+
const revParse = await git.raw(['rev-parse', 'HEAD']).catch(() => '');
|
|
4422
|
+
if (!revParse.trim()) {
|
|
4423
|
+
headState = 'unborn';
|
|
4424
|
+
branch = null;
|
|
4425
|
+
} else {
|
|
4426
|
+
headState = 'detached';
|
|
4427
|
+
branch = revParse.trim().slice(0, 7);
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
} catch {
|
|
4431
|
+
headState = 'unborn';
|
|
4432
|
+
branch = null;
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
// Detect attention reasons from getStatus side-effects
|
|
4436
|
+
try {
|
|
4437
|
+
const status = await git.status(['-uall']);
|
|
4438
|
+
if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) {
|
|
4439
|
+
attentionReason = 'merge';
|
|
4440
|
+
} else {
|
|
4441
|
+
const rebaseMergePath = await resolveGitInternalPath(repoRoot, git, 'rebase-merge').catch(() => '');
|
|
4442
|
+
const rebaseApplyPath = await resolveGitInternalPath(repoRoot, git, 'rebase-apply').catch(() => '');
|
|
4443
|
+
const rebaseMerge = rebaseMergePath ? await fsp.stat(rebaseMergePath).then(() => true).catch(() => false) : false;
|
|
4444
|
+
const rebaseApply = rebaseApplyPath ? await fsp.stat(rebaseApplyPath).then(() => true).catch(() => false) : false;
|
|
4445
|
+
if (rebaseMerge || rebaseApply) {
|
|
4446
|
+
attentionReason = 'rebase';
|
|
4447
|
+
} else if (status.conflicted && status.conflicted.length > 0) {
|
|
4448
|
+
const cherryPickHeadPath = await resolveGitInternalPath(repoRoot, git, 'CHERRY_PICK_HEAD').catch(() => '');
|
|
4449
|
+
const revertHeadPath = await resolveGitInternalPath(repoRoot, git, 'REVERT_HEAD').catch(() => '');
|
|
4450
|
+
const cherryPickHead = cherryPickHeadPath ? await fsp.stat(cherryPickHeadPath).then(() => true).catch(() => false) : false;
|
|
4451
|
+
const revertHead = revertHeadPath ? await fsp.stat(revertHeadPath).then(() => true).catch(() => false) : false;
|
|
4452
|
+
if (cherryPickHead) attentionReason = 'cherry-pick';
|
|
4453
|
+
else if (revertHead) attentionReason = 'revert';
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4456
|
+
} catch {
|
|
4457
|
+
// Status check failed — ignore
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
return {
|
|
4461
|
+
worktreeRoot,
|
|
4462
|
+
cwd,
|
|
4463
|
+
branch,
|
|
4464
|
+
headState,
|
|
4465
|
+
worktreeStatus,
|
|
4466
|
+
legacy: false,
|
|
4467
|
+
degraded: false,
|
|
4468
|
+
attentionReason,
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
export async function getCommitFiles(directory, commitHash) {
|
|
4473
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4474
|
+
|
|
4475
|
+
try {
|
|
4476
|
+
|
|
4477
|
+
const numstatRaw = await git.raw([
|
|
4478
|
+
'show',
|
|
4479
|
+
'--numstat',
|
|
4480
|
+
'--format=',
|
|
4481
|
+
commitHash
|
|
4482
|
+
]);
|
|
4483
|
+
|
|
4484
|
+
const files = [];
|
|
4485
|
+
const lines = numstatRaw.trim().split('\n').filter(Boolean);
|
|
4486
|
+
|
|
4487
|
+
for (const line of lines) {
|
|
4488
|
+
const parts = line.split('\t');
|
|
4489
|
+
if (parts.length < 3) continue;
|
|
4490
|
+
|
|
4491
|
+
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
|
4492
|
+
const filePath = pathParts.join('\t');
|
|
4493
|
+
if (!filePath) continue;
|
|
4494
|
+
|
|
4495
|
+
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
|
4496
|
+
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
|
4497
|
+
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
|
4498
|
+
|
|
4499
|
+
let changeType = 'M';
|
|
4500
|
+
let displayPath = filePath;
|
|
4501
|
+
|
|
4502
|
+
if (filePath.includes(' => ')) {
|
|
4503
|
+
changeType = 'R';
|
|
4504
|
+
|
|
4505
|
+
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
|
|
4506
|
+
if (match) {
|
|
4507
|
+
displayPath = filePath;
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
files.push({
|
|
4512
|
+
path: displayPath,
|
|
4513
|
+
insertions,
|
|
4514
|
+
deletions,
|
|
4515
|
+
isBinary,
|
|
4516
|
+
changeType
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
const nameStatusRaw = await git.raw([
|
|
4521
|
+
'show',
|
|
4522
|
+
'--name-status',
|
|
4523
|
+
'--format=',
|
|
4524
|
+
commitHash
|
|
4525
|
+
]).catch(() => '');
|
|
4526
|
+
|
|
4527
|
+
const statusMap = new Map();
|
|
4528
|
+
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
|
|
4529
|
+
for (const line of statusLines) {
|
|
4530
|
+
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
|
4531
|
+
if (match) {
|
|
4532
|
+
const [, status, pathPart] = match;
|
|
4533
|
+
statusMap.set(extractGitStatusPath(status, pathPart), status);
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
for (const file of files) {
|
|
4538
|
+
const basePath = extractGitNumstatDestinationPath(file.path);
|
|
4539
|
+
|
|
4540
|
+
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
|
4541
|
+
if (status) {
|
|
4542
|
+
file.changeType = status;
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
return { files };
|
|
4547
|
+
} catch (error) {
|
|
4548
|
+
console.error('Failed to get commit files:', error);
|
|
4549
|
+
throw error;
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
export async function renameBranch(directory, oldName, newName) {
|
|
4554
|
+
const { git, repoRoot } = await createRepositoryGitContext(directory);
|
|
4555
|
+
|
|
4556
|
+
try {
|
|
4557
|
+
const normalizedOldName = cleanBranchName(String(oldName || '').trim());
|
|
4558
|
+
const normalizedNewName = cleanBranchName(String(newName || '').trim());
|
|
4559
|
+
|
|
4560
|
+
const previousRemote = await git
|
|
4561
|
+
.raw(['config', '--get', `branch.${normalizedOldName}.remote`])
|
|
4562
|
+
.then((value) => String(value || '').trim())
|
|
4563
|
+
.catch(() => '');
|
|
4564
|
+
const previousMerge = await git
|
|
4565
|
+
.raw(['config', '--get', `branch.${normalizedOldName}.merge`])
|
|
4566
|
+
.then((value) => String(value || '').trim())
|
|
4567
|
+
.catch(() => '');
|
|
4568
|
+
|
|
4569
|
+
// Use git branch -m command to rename the branch
|
|
4570
|
+
await git.raw(['branch', '-m', oldName, newName]);
|
|
4571
|
+
|
|
4572
|
+
if (previousRemote && previousMerge && normalizedNewName) {
|
|
4573
|
+
const previousMergeBranch = cleanBranchName(previousMerge);
|
|
4574
|
+
const nextMergeBranch =
|
|
4575
|
+
previousMergeBranch === normalizedOldName
|
|
4576
|
+
? normalizedNewName
|
|
4577
|
+
: previousMergeBranch;
|
|
4578
|
+
const upstream = normalizeUpstreamTarget(previousRemote, nextMergeBranch);
|
|
4579
|
+
|
|
4580
|
+
if (upstream) {
|
|
4581
|
+
try {
|
|
4582
|
+
await runGitCommandOrThrow(
|
|
4583
|
+
repoRoot,
|
|
4584
|
+
['branch', `--set-upstream-to=${upstream.full}`, normalizedNewName],
|
|
4585
|
+
`Failed to set upstream to ${upstream.full}`
|
|
4586
|
+
);
|
|
4587
|
+
} catch {
|
|
4588
|
+
await setBranchTrackingFallback(repoRoot, normalizedNewName, upstream);
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
return { success: true, branch: newName };
|
|
4594
|
+
} catch (error) {
|
|
4595
|
+
console.error('Failed to rename branch:', error);
|
|
4596
|
+
throw error;
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
export async function getRemotes(directory) {
|
|
4601
|
+
try {
|
|
4602
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4603
|
+
const remotes = await git.getRemotes(true);
|
|
4604
|
+
|
|
4605
|
+
return remotes.map((remote) => ({
|
|
4606
|
+
name: remote.name,
|
|
4607
|
+
fetchUrl: remote.refs.fetch,
|
|
4608
|
+
pushUrl: remote.refs.push
|
|
4609
|
+
}));
|
|
4610
|
+
} catch (error) {
|
|
4611
|
+
if (isNotGitRepositoryError(error)) {
|
|
4612
|
+
return [];
|
|
4613
|
+
}
|
|
4614
|
+
console.error('Failed to get remotes:', error);
|
|
4615
|
+
throw error;
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
export async function removeRemote(directory, options = {}) {
|
|
4620
|
+
const remoteName = String(options.remote || '').trim();
|
|
4621
|
+
if (!remoteName) {
|
|
4622
|
+
throw new Error('remote is required to remove a remote');
|
|
4623
|
+
}
|
|
4624
|
+
if (remoteName === 'origin') {
|
|
4625
|
+
throw new Error('Cannot remove origin remote');
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4629
|
+
|
|
4630
|
+
try {
|
|
4631
|
+
await git.removeRemote(remoteName);
|
|
4632
|
+
return { success: true };
|
|
4633
|
+
} catch (error) {
|
|
4634
|
+
console.error('Failed to remove remote:', error);
|
|
4635
|
+
throw error;
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
export async function rebase(directory, options = {}) {
|
|
4640
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4641
|
+
|
|
4642
|
+
try {
|
|
4643
|
+
const { onto } = options;
|
|
4644
|
+
if (!onto) {
|
|
4645
|
+
throw new Error('onto parameter is required for rebase');
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
await git.rebase([onto]);
|
|
4649
|
+
|
|
4650
|
+
return {
|
|
4651
|
+
success: true,
|
|
4652
|
+
conflict: false
|
|
4653
|
+
};
|
|
4654
|
+
} catch (error) {
|
|
4655
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
4656
|
+
const isConflict = errorMessage.includes('conflict') ||
|
|
4657
|
+
errorMessage.includes('could not apply') ||
|
|
4658
|
+
errorMessage.includes('merge conflict');
|
|
4659
|
+
|
|
4660
|
+
if (isConflict) {
|
|
4661
|
+
// Get list of conflicted files
|
|
4662
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
4663
|
+
return {
|
|
4664
|
+
success: false,
|
|
4665
|
+
conflict: true,
|
|
4666
|
+
conflictFiles: status.conflicted || []
|
|
4667
|
+
};
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
console.error('Failed to rebase:', error);
|
|
4671
|
+
throw error;
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
export async function abortRebase(directory) {
|
|
4676
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4677
|
+
|
|
4678
|
+
try {
|
|
4679
|
+
await git.rebase(['--abort']);
|
|
4680
|
+
return { success: true };
|
|
4681
|
+
} catch (error) {
|
|
4682
|
+
console.error('Failed to abort rebase:', error);
|
|
4683
|
+
throw error;
|
|
4684
|
+
}
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
export async function merge(directory, options = {}) {
|
|
4688
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4689
|
+
|
|
4690
|
+
try {
|
|
4691
|
+
const { branch } = options;
|
|
4692
|
+
if (!branch) {
|
|
4693
|
+
throw new Error('branch parameter is required for merge');
|
|
4694
|
+
}
|
|
4695
|
+
|
|
4696
|
+
await git.merge([branch]);
|
|
4697
|
+
|
|
4698
|
+
return {
|
|
4699
|
+
success: true,
|
|
4700
|
+
conflict: false
|
|
4701
|
+
};
|
|
4702
|
+
} catch (error) {
|
|
4703
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
4704
|
+
const isConflict = errorMessage.includes('conflict') ||
|
|
4705
|
+
errorMessage.includes('merge conflict') ||
|
|
4706
|
+
errorMessage.includes('automatic merge failed');
|
|
4707
|
+
|
|
4708
|
+
if (isConflict) {
|
|
4709
|
+
// Get list of conflicted files
|
|
4710
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
4711
|
+
return {
|
|
4712
|
+
success: false,
|
|
4713
|
+
conflict: true,
|
|
4714
|
+
conflictFiles: status.conflicted || []
|
|
4715
|
+
};
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
console.error('Failed to merge:', error);
|
|
4719
|
+
throw error;
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4723
|
+
export async function abortMerge(directory) {
|
|
4724
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4725
|
+
|
|
4726
|
+
try {
|
|
4727
|
+
await git.merge(['--abort']);
|
|
4728
|
+
return { success: true };
|
|
4729
|
+
} catch (error) {
|
|
4730
|
+
console.error('Failed to abort merge:', error);
|
|
4731
|
+
throw error;
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
|
|
4735
|
+
export async function continueRebase(directory) {
|
|
4736
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4737
|
+
|
|
4738
|
+
try {
|
|
4739
|
+
// Set GIT_EDITOR to prevent editor prompts
|
|
4740
|
+
await git.env('GIT_EDITOR', 'true').rebase(['--continue']);
|
|
4741
|
+
return { success: true, conflict: false };
|
|
4742
|
+
} catch (error) {
|
|
4743
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
4744
|
+
const isConflict = errorMessage.includes('conflict') ||
|
|
4745
|
+
errorMessage.includes('needs merge') ||
|
|
4746
|
+
errorMessage.includes('unmerged') ||
|
|
4747
|
+
errorMessage.includes('fix conflicts');
|
|
4748
|
+
|
|
4749
|
+
if (isConflict) {
|
|
4750
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
4751
|
+
return {
|
|
4752
|
+
success: false,
|
|
4753
|
+
conflict: true,
|
|
4754
|
+
conflictFiles: status.conflicted || []
|
|
4755
|
+
};
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
// Check for "nothing to commit" which means rebase step is complete
|
|
4759
|
+
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes')) {
|
|
4760
|
+
// Skip this commit and continue
|
|
4761
|
+
try {
|
|
4762
|
+
await git.env('GIT_EDITOR', 'true').rebase(['--skip']);
|
|
4763
|
+
return { success: true, conflict: false };
|
|
4764
|
+
} catch {
|
|
4765
|
+
// If skip also fails, the rebase may be complete
|
|
4766
|
+
return { success: true, conflict: false };
|
|
4767
|
+
}
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
console.error('Failed to continue rebase:', error);
|
|
4771
|
+
throw error;
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
export async function continueMerge(directory) {
|
|
4776
|
+
const { git } = await createRepositoryGitContext(directory);
|
|
4777
|
+
|
|
4778
|
+
try {
|
|
4779
|
+
// Check if there are still unmerged files
|
|
4780
|
+
const status = await git.status();
|
|
4781
|
+
if (status.conflicted && status.conflicted.length > 0) {
|
|
4782
|
+
return {
|
|
4783
|
+
success: false,
|
|
4784
|
+
conflict: true,
|
|
4785
|
+
conflictFiles: status.conflicted
|
|
4786
|
+
};
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
// For merge, we commit after resolving conflicts
|
|
4790
|
+
// Use --no-edit to use the default merge commit message
|
|
4791
|
+
await git.env('GIT_EDITOR', 'true').commit([], { '--no-edit': null });
|
|
4792
|
+
return { success: true, conflict: false };
|
|
4793
|
+
} catch (error) {
|
|
4794
|
+
const errorMessage = String(error?.message || error || '').toLowerCase();
|
|
4795
|
+
const isConflict = errorMessage.includes('conflict') ||
|
|
4796
|
+
errorMessage.includes('needs merge') ||
|
|
4797
|
+
errorMessage.includes('unmerged') ||
|
|
4798
|
+
errorMessage.includes('fix conflicts');
|
|
4799
|
+
|
|
4800
|
+
if (isConflict) {
|
|
4801
|
+
const status = await git.status().catch(() => ({ conflicted: [] }));
|
|
4802
|
+
return {
|
|
4803
|
+
success: false,
|
|
4804
|
+
conflict: true,
|
|
4805
|
+
conflictFiles: status.conflicted || []
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
|
|
4809
|
+
// "nothing to commit" can happen if all conflicts resolved to one side
|
|
4810
|
+
if (errorMessage.includes('nothing to commit') || errorMessage.includes('no changes added')) {
|
|
4811
|
+
// The merge is effectively complete (all changes already committed or no changes needed)
|
|
4812
|
+
return { success: true, conflict: false };
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
console.error('Failed to continue merge:', error);
|
|
4816
|
+
throw error;
|
|
4817
|
+
}
|
|
4818
|
+
}
|
|
4819
|
+
|
|
4820
|
+
export async function getConflictDetails(directory) {
|
|
4821
|
+
const { repoRoot, git } = await createRepositoryGitContext(directory);
|
|
4822
|
+
|
|
4823
|
+
try {
|
|
4824
|
+
// Get git status --porcelain
|
|
4825
|
+
const statusPorcelain = await git.raw(['status', '--porcelain']).catch(() => '');
|
|
4826
|
+
|
|
4827
|
+
// Get unmerged files
|
|
4828
|
+
const unmergedFilesRaw = await git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => '');
|
|
4829
|
+
const unmergedFiles = unmergedFilesRaw
|
|
4830
|
+
.split('\n')
|
|
4831
|
+
.map((line) => line.trim())
|
|
4832
|
+
.filter(Boolean);
|
|
4833
|
+
|
|
4834
|
+
// Get current diff
|
|
4835
|
+
const diff = await git.raw(['diff']).catch(() => '');
|
|
4836
|
+
|
|
4837
|
+
// Detect operation type and get head info
|
|
4838
|
+
let operation = 'merge';
|
|
4839
|
+
let headInfo = '';
|
|
4840
|
+
|
|
4841
|
+
// Check for MERGE_HEAD (merge in progress)
|
|
4842
|
+
const mergeHeadExists = await git
|
|
4843
|
+
.raw(['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
|
|
4844
|
+
.then(() => true)
|
|
4845
|
+
.catch(() => false);
|
|
4846
|
+
|
|
4847
|
+
if (mergeHeadExists) {
|
|
4848
|
+
operation = 'merge';
|
|
4849
|
+
const mergeHead = await git.raw(['rev-parse', 'MERGE_HEAD']).catch(() => '');
|
|
4850
|
+
const mergeMsgPath = await resolveGitInternalPath(repoRoot, git, 'MERGE_MSG').catch(() => '');
|
|
4851
|
+
const mergeMsg = mergeMsgPath ? await fsp.readFile(mergeMsgPath, 'utf8').catch(() => '') : '';
|
|
4852
|
+
headInfo = `MERGE_HEAD: ${mergeHead.trim()}\n${mergeMsg}`;
|
|
4853
|
+
} else {
|
|
4854
|
+
// Check for REBASE_HEAD (rebase in progress)
|
|
4855
|
+
const rebaseHeadExists = await git
|
|
4856
|
+
.raw(['rev-parse', '--verify', '--quiet', 'REBASE_HEAD'])
|
|
4857
|
+
.then(() => true)
|
|
4858
|
+
.catch(() => false);
|
|
4859
|
+
|
|
4860
|
+
if (rebaseHeadExists) {
|
|
4861
|
+
operation = 'rebase';
|
|
4862
|
+
const rebaseHead = await git.raw(['rev-parse', 'REBASE_HEAD']).catch(() => '');
|
|
4863
|
+
headInfo = `REBASE_HEAD: ${rebaseHead.trim()}`;
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
return {
|
|
4868
|
+
statusPorcelain: statusPorcelain.trim(),
|
|
4869
|
+
unmergedFiles,
|
|
4870
|
+
diff: diff.trim(),
|
|
4871
|
+
headInfo: headInfo.trim(),
|
|
4872
|
+
operation,
|
|
4873
|
+
};
|
|
4874
|
+
} catch (error) {
|
|
4875
|
+
console.error('Failed to get conflict details:', error);
|
|
4876
|
+
throw error;
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
export async function getCommitFileDiff(directory, hash, filePath, isBinary) {
|
|
4881
|
+
if (!directory || !hash || !filePath) {
|
|
4882
|
+
throw new Error('directory, hash, and path are required for getCommitFileDiff');
|
|
4883
|
+
}
|
|
4884
|
+
|
|
4885
|
+
if (isBinary) {
|
|
4886
|
+
return { original: '', modified: '', isBinary: true };
|
|
4887
|
+
}
|
|
4888
|
+
|
|
4889
|
+
const { directoryPath, repoRoot } = await createRepositoryGitContext(directory);
|
|
4890
|
+
const candidates = Array.from(new Set([
|
|
4891
|
+
toGitPath(path.relative(repoRoot, path.resolve(repoRoot, filePath))),
|
|
4892
|
+
toGitPath(path.relative(repoRoot, path.resolve(directoryPath, filePath))),
|
|
4893
|
+
])).filter((candidate) => candidate && !candidate.startsWith('..') && !path.isAbsolute(candidate));
|
|
4894
|
+
|
|
4895
|
+
let originalResult = null;
|
|
4896
|
+
let modifiedResult = null;
|
|
4897
|
+
|
|
4898
|
+
for (const candidate of candidates) {
|
|
4899
|
+
const [candidateOriginalResult, candidateModifiedResult] = await Promise.all([
|
|
4900
|
+
runGitCommand(repoRoot, ['show', `${hash}^:${candidate}`]),
|
|
4901
|
+
runGitCommand(repoRoot, ['show', `${hash}:${candidate}`]),
|
|
4902
|
+
]);
|
|
4903
|
+
|
|
4904
|
+
if (candidateOriginalResult.success || candidateModifiedResult.success) {
|
|
4905
|
+
originalResult = candidateOriginalResult;
|
|
4906
|
+
modifiedResult = candidateModifiedResult;
|
|
4907
|
+
break;
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
if (!originalResult || !modifiedResult) {
|
|
4912
|
+
const resolvedPath = await resolveGitCommitFilePath(repoRoot, hash, candidates);
|
|
4913
|
+
[originalResult, modifiedResult] = await Promise.all([
|
|
4914
|
+
runGitCommand(repoRoot, ['show', `${hash}^:${resolvedPath}`]),
|
|
4915
|
+
runGitCommand(repoRoot, ['show', `${hash}:${resolvedPath}`]),
|
|
4916
|
+
]);
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
const original = originalResult.success ? originalResult.stdout : '';
|
|
4920
|
+
const modified = modifiedResult.success ? modifiedResult.stdout : '';
|
|
4921
|
+
|
|
4922
|
+
if (!originalResult.success && !modifiedResult.success) {
|
|
4923
|
+
throw new Error(`Failed to read file content at commit ${hash}: ${originalResult.stderr || modifiedResult.stderr}`);
|
|
4924
|
+
}
|
|
4925
|
+
|
|
4926
|
+
return { original, modified, isBinary: false };
|
|
4927
|
+
}
|