@vantran-se/openchamber-web 1.23.0 → 1.24.3
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/bin/cli.test.js +29 -6
- package/bin/lib/cli-http.js +5 -1
- package/bin/lib/commands-status.js +5 -0
- package/dist/assets/{CodeMirrorCommentWidgets-DFg2wnpo.js → CodeMirrorCommentWidgets-BjJHrzmU.js} +1 -1
- package/dist/assets/DiffView-COVQhWGd.js +4 -0
- package/dist/assets/FilesView-B1V5JxFX.js +13 -0
- package/dist/assets/GitView-CDOJy5Ay.js +6 -0
- package/dist/assets/GuestAttachDialog-D3S0Rjic.js +1 -0
- package/dist/assets/{JsonTreeView-BKdsjg5B.js → JsonTreeView-D0C9nU7K.js} +1 -1
- 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/LinearIssuesView-D7FKg1XJ.js +1 -0
- package/dist/assets/{MarkdownImageGallery-_5gEVr0X.js → MarkdownImageGallery-BrFjAVEf.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-DWepTafh.js +12 -0
- package/dist/assets/{OnboardingScreen-DdP4_nuc.js → OnboardingScreen-Dyxxiqdt.js} +1 -1
- package/dist/assets/{PatchDiff-am3SAss2.js → PatchDiff-DnEhjXcC.js} +1 -1
- package/dist/assets/PierreDiffViewer-DyxZVVVR.js +210 -0
- package/dist/assets/{PlanView-BoJxdRZe.js → PlanView-LW2z_EAP.js} +4 -4
- package/dist/assets/PluginPane-B4tKno2h.js +3 -0
- package/dist/assets/SettingsView-DEaMulN9.js +29 -0
- package/dist/assets/{SettingsWindow-DVTdDSol.js → SettingsWindow-FLDJXOgQ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +1 -0
- package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-C7WLk5i9.js → ToolPartDiffPreview-CkR4msKP.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DcdgkH-x.js → VirtualizedFileDiff-0pyr_mHY.js} +4 -4
- package/dist/assets/WalkthroughView-yNj4nOP5.js +4 -0
- package/dist/assets/appThemeRegistry-BuUKhbYQ.js +1 -0
- package/dist/assets/commitAndPush-DvJKa3Ks.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-C8byMnRh.js → computeVirtualFileMetrics-DxTBMuu-.js} +2 -2
- package/dist/assets/de-DgxgFyGG.js +10 -0
- package/dist/assets/es-BFsZj5F_.js +16 -0
- package/dist/assets/fr-jdZ5KKQi.js +14 -0
- package/dist/assets/{index-BB7ryz8y.js → index-CKHjPKbq.js} +1 -1
- package/dist/assets/index-Cbci4i1W.js +40 -0
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/{isDiffPlainText-45ujMPGb.js → isDiffPlainText-D7FkBMP6.js} +1 -1
- package/dist/assets/{isFilePlainText-Cc4KFSYi.js → isFilePlainText-CL2vsrIZ.js} +1 -1
- package/dist/assets/ja-C5kKrhO4.js +16 -0
- package/dist/assets/ko-DXDTT_0O.js +16 -0
- package/dist/assets/main-CZALtVek.js +896 -0
- package/dist/assets/main-CgXMBNvG.js +2 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-Dh7WEsdM.js +5 -0
- package/dist/assets/miniChat-CwTmDixi.js +2 -0
- package/dist/assets/mobile-CLf2vCtO.js +2 -0
- package/dist/assets/patchFileDiff-Bsba8KFp.js +13 -0
- package/dist/assets/pl-D-7x4Y8Q.js +16 -0
- package/dist/assets/pt-BR-CSBZrHa8.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +2 -0
- package/dist/assets/renderMobileApp-V4hYd32H.js +25 -0
- package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +25 -0
- package/dist/assets/scheduledTasksApi-DzjbLd8P.js +1 -0
- package/dist/assets/shikiHighlight-8_3csmr6.js +1 -0
- package/dist/assets/surface-B6n7wJtt.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-CHo2fCgW.js +1 -0
- package/dist/assets/tr-Dr-oJ22L.js +7 -0
- package/dist/assets/uk-9Nx83UcI.js +16 -0
- package/dist/assets/useAppFontEffects-khZXpFL_.js +7333 -0
- package/dist/assets/useInlineCommentController-DkQ_gqpn.js +2 -0
- package/dist/assets/useRootScrollLock-DdvHf9Pt.js +2 -0
- package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-CsXYnR5c.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DzlSIu_W.js → vendor-codemirror-language-data-CdzUmUyZ.js} +2 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
- package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
- package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
- package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
- package/dist/assets/vendor-zod-CgAdXWZO.js +39 -0
- package/dist/assets/{web-components-CdGfR2XJ.js → web-components-B8IcVQAA.js} +2 -2
- package/dist/assets/zh-CN-Bxy_Hj3r.js +16 -0
- package/dist/assets/zh-TW-0BB4gpHM.js +16 -0
- package/dist/index.html +6 -4
- package/dist/mini-chat.html +6 -4
- package/dist/mobile.html +6 -4
- package/package.json +12 -9
- package/server/built-in-extensions/registry.json +4 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +47 -23
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
- package/server/lib/agent-tool/runtime.js +62 -15
- package/server/lib/agent-tool/runtime.test.js +88 -2
- package/server/lib/changelog/update-notes.js +2 -1
- package/server/lib/changelog/update-notes.test.js +19 -0
- package/server/lib/event-stream/DOCUMENTATION.md +26 -0
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +292 -0
- package/server/lib/event-stream/global-hub.js +68 -13
- package/server/lib/event-stream/global-hub.test.js +182 -0
- package/server/lib/event-stream/global-ws-bridge.js +16 -18
- package/server/lib/event-stream/index.js +4 -0
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +60 -0
- package/server/lib/fs/DOCUMENTATION.md +10 -1
- package/server/lib/fs/git-process.test.js +77 -0
- package/server/lib/fs/routes.js +58 -5
- package/server/lib/fs/routes.test.js +116 -0
- package/server/lib/fs/search.js +2 -1
- package/server/lib/git/DOCUMENTATION.md +30 -6
- package/server/lib/git/routes.js +37 -11
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/serial-refresh.js +93 -0
- package/server/lib/git/serial-refresh.test.js +112 -0
- package/server/lib/git/service.js +588 -74
- package/server/lib/git/service.test.js +492 -0
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/pr-status.js +5 -5
- package/server/lib/github/pr-status.test.js +1 -1
- package/server/lib/github/routes.js +6 -6
- package/server/lib/guests/DOCUMENTATION.md +68 -0
- package/server/lib/guests/auth-store.js +184 -0
- package/server/lib/guests/auth-store.test.js +112 -0
- package/server/lib/guests/background.test.js +83 -0
- package/server/lib/guests/builtins.js +34 -0
- package/server/lib/guests/builtins.test.js +174 -0
- package/server/lib/guests/catalog.js +477 -0
- package/server/lib/guests/catalog.test.js +390 -0
- package/server/lib/guests/clone.js +297 -0
- package/server/lib/guests/extract-zip.js +136 -0
- package/server/lib/guests/extract-zip.test.js +109 -0
- package/server/lib/guests/files.js +308 -0
- package/server/lib/guests/files.test.js +262 -0
- package/server/lib/guests/grant-scope.js +81 -0
- package/server/lib/guests/grant-scope.test.js +75 -0
- package/server/lib/guests/host-session.js +73 -0
- package/server/lib/guests/host-session.test.js +30 -0
- package/server/lib/guests/html-styles.js +6 -0
- package/server/lib/guests/html-styles.test.js +45 -0
- package/server/lib/guests/html-tokens.js +73 -0
- package/server/lib/guests/html-tokens.test.js +53 -0
- package/server/lib/guests/install.js +406 -0
- package/server/lib/guests/install.test.js +338 -0
- package/server/lib/guests/oauth.js +501 -0
- package/server/lib/guests/oauth.test.js +481 -0
- package/server/lib/guests/persist.js +336 -0
- package/server/lib/guests/persist.test.js +288 -0
- package/server/lib/guests/request.js +112 -0
- package/server/lib/guests/request.test.js +206 -0
- package/server/lib/guests/routes.js +838 -0
- package/server/lib/guests/service.js +538 -0
- package/server/lib/guests/service.test.js +277 -0
- package/server/lib/guests/sockets.js +129 -0
- package/server/lib/guests/sockets.test.js +110 -0
- package/server/lib/guests/ssh-install.test.js +188 -0
- package/server/lib/guests/storage.js +68 -0
- package/server/lib/guests/storage.test.js +59 -0
- package/server/lib/guests/updates.js +294 -0
- package/server/lib/guests/updates.test.js +334 -0
- package/server/lib/guests/upload.js +50 -0
- package/server/lib/guests/upload.test.js +131 -0
- package/server/lib/linear/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/DOCUMENTATION.md +17 -1
- package/server/lib/message-queue/runtime.js +34 -1
- package/server/lib/message-queue/runtime.test.js +83 -6
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/notifications/routes.js +5 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
- package/server/lib/openchamber-sessions/routes.js +62 -29
- package/server/lib/openchamber-sessions/routes.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +73 -5
- package/server/lib/opencode/bootstrap-runtime.js +1 -0
- package/server/lib/opencode/core-routes.js +39 -1
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +30 -1
- package/server/lib/opencode/lifecycle-process.test.js +84 -0
- package/server/lib/opencode/lifecycle.js +58 -70
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/openchamber-routes.js +72 -14
- package/server/lib/opencode/openchamber-routes.test.js +71 -3
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/session-runtime.js +68 -0
- package/server/lib/opencode/session-runtime.test.js +43 -0
- package/server/lib/opencode/settings-helpers.js +11 -0
- package/server/lib/opencode/settings-helpers.test.js +24 -2
- package/server/lib/opencode/settings-normalization-runtime.js +2 -0
- package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
- package/server/lib/opencode/settings-registry.json +18 -0
- package/server/lib/opencode/settings-runtime.js +36 -9
- package/server/lib/opencode/settings-runtime.test.js +158 -2
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/shutdown-runtime.test.js +24 -0
- package/server/lib/opencode/theme-archive.js +53 -0
- package/server/lib/opencode/theme-catalog.js +171 -0
- package/server/lib/opencode/theme-catalog.test.js +125 -0
- package/server/lib/opencode/theme-import.test.js +144 -0
- package/server/lib/opencode/theme-runtime.js +100 -28
- package/server/lib/opencode/theme-runtime.test.js +15 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
- package/server/lib/permission-auto-accept/runtime.js +13 -0
- package/server/lib/permission-auto-accept/runtime.test.js +37 -1
- package/server/lib/project-context/DOCUMENTATION.md +13 -3
- package/server/lib/project-context/runtime.js +8 -2
- package/server/lib/project-context/runtime.test.js +54 -0
- package/server/lib/projects/DOCUMENTATION.md +2 -1
- package/server/lib/projects/project-config.js +40 -11
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +26 -0
- package/server/lib/quota/DOCUMENTATION.md +0 -1
- package/server/lib/quota/providers/index.js +0 -7
- package/server/lib/realtime-proxy-terminal.test.js +150 -0
- package/server/lib/realtime-proxy.js +2 -0
- package/server/lib/relay/DOCUMENTATION.md +20 -0
- package/server/lib/relay/host-client.js +7 -0
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/routing/DOCUMENTATION.md +83 -0
- package/server/lib/routing/defaults.js +98 -0
- package/server/lib/routing/feature-flag.js +11 -0
- package/server/lib/routing/history.js +34 -0
- package/server/lib/routing/jev.js +98 -0
- package/server/lib/routing/routes.http.test.js +100 -0
- package/server/lib/routing/routes.js +81 -0
- package/server/lib/routing/runtime.js +217 -0
- package/server/lib/routing/runtime.test.js +188 -0
- package/server/lib/routing/store.js +249 -0
- package/server/lib/routing/store.test.js +96 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +14 -4
- package/server/lib/scheduled-tasks/runtime.test.js +67 -0
- package/server/lib/security/request-security.js +9 -5
- package/server/lib/security/request-security.test.js +37 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +111 -257
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/small-model/DOCUMENTATION.md +14 -2
- package/server/lib/small-model/call.js +37 -6
- package/server/lib/small-model/call.test.js +182 -0
- package/server/lib/terminal/DOCUMENTATION.md +23 -2
- package/server/lib/terminal/runtime.js +27 -5
- package/server/lib/terminal/runtime.test.js +67 -0
- package/server/lib/terminal/shutdown.js +106 -0
- package/server/lib/terminal/shutdown.test.js +84 -0
- package/server/lib/tts/service.test.js +78 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
- package/server/lib/ui-auth/session-cookie.js +51 -0
- package/server/lib/ui-auth/session-cookie.test.js +46 -0
- package/server/lib/ui-auth/ui-auth.js +56 -17
- package/server/lib/ui-auth/ui-auth.test.js +96 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +13 -2
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-3Dn4QHbu.js +0 -4
- package/dist/assets/DirtyBranchSwitchDialog-CwOerqVi.js +0 -1
- package/dist/assets/FilesView-BdTOpew9.js +0 -17
- package/dist/assets/GitView-Dqf9vHJh.js +0 -6
- package/dist/assets/LinearIssuesView-CmbeL7I6.js +0 -1
- package/dist/assets/MarkdownRendererImpl-wVZlx4dd.js +0 -12
- package/dist/assets/PierreDiffViewer-DCsydlKs.js +0 -161
- package/dist/assets/SettingsView-CcM8pF5O.js +0 -28
- package/dist/assets/ToolOutputDialog-BgfTbDZ4.js +0 -15
- package/dist/assets/WalkthroughView-BEVUDxuw.js +0 -4
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
- package/dist/assets/de-C8jV7J9y.js +0 -10
- package/dist/assets/es-BI6J1M5R.js +0 -16
- package/dist/assets/fr-D6oDFt7E.js +0 -14
- package/dist/assets/index-BqhiCmYS.js +0 -39
- package/dist/assets/index-FqSOBz0J.css +0 -1
- package/dist/assets/ja-C_4i5EQu.js +0 -16
- package/dist/assets/ko-BFbOTpbq.js +0 -16
- package/dist/assets/main-C9ETLKFc.js +0 -2
- package/dist/assets/main-CJmb2dF8.js +0 -930
- package/dist/assets/markdownCore-DVuZpnli.js +0 -5
- package/dist/assets/miniChat-B4Ytoq2n.js +0 -2
- package/dist/assets/mobile-BUT6-lo1.js +0 -2
- package/dist/assets/patchFileDiff-8j1apICU.js +0 -13
- package/dist/assets/pl-COFvT56l.js +0 -16
- package/dist/assets/pt-BR-DtVOO2Lj.js +0 -16
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-B6vUG-vF.js +0 -2
- package/dist/assets/renderMobileApp-BNgHraq6.js +0 -25
- package/dist/assets/runtimeEndpointReset-B8LYpWvr.js +0 -17
- package/dist/assets/scheduledTasksApi-GC4epIYA.js +0 -1
- package/dist/assets/shikiHighlight-BJISeZsQ.js +0 -1
- package/dist/assets/surface-BvRzS58U.js +0 -1
- package/dist/assets/tr-B8Q0Go2l.js +0 -7
- package/dist/assets/uk-Dk4a-Fn-.js +0 -16
- package/dist/assets/useAppFontEffects-DpFNefUA.js +0 -7284
- package/dist/assets/useGitComparison-Dfxaqd0s.js +0 -1
- package/dist/assets/useInlineCommentController-CscsE4t-.js +0 -2
- package/dist/assets/useRootScrollLock-BaCvuRlT.js +0 -1
- package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
- package/dist/assets/vendor-zod-BPAANqvH.js +0 -39
- package/dist/assets/zh-CN-B_BXxG1Q.js +0 -16
- package/dist/assets/zh-TW-DLrl1SPZ.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
- package/server/lib/quota/providers/crof.js +0 -96
- package/server/lib/quota/providers/crof.test.js +0 -98
- /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import simpleGit from 'simple-git';
|
|
2
|
+
import { createSerialRefresh } from './serial-refresh.js';
|
|
2
3
|
import fs from 'fs';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import os from 'os';
|
|
5
|
-
import { execFile } from 'child_process';
|
|
6
|
+
import { execFile, spawn } from 'child_process';
|
|
6
7
|
import { promisify } from 'util';
|
|
7
8
|
import { createRequire } from 'module';
|
|
8
9
|
|
|
@@ -349,12 +350,22 @@ const buildGitEnv = async () => {
|
|
|
349
350
|
env.SSH_AUTH_SOCK = resolved;
|
|
350
351
|
}
|
|
351
352
|
}
|
|
353
|
+
// The server has no terminal a user could answer. Without this, Git asks
|
|
354
|
+
// for a username or password on its (hidden, on Windows) console and waits
|
|
355
|
+
// forever; credential helpers and GUI prompts still run before this point.
|
|
356
|
+
if (env.GIT_TERMINAL_PROMPT === undefined) {
|
|
357
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
358
|
+
}
|
|
352
359
|
return env;
|
|
353
360
|
};
|
|
354
361
|
|
|
355
|
-
const createGit = async (directory, { allowUnsafeSshCommand = false, allowUnsafeCredentialHelper = false } = {}) => {
|
|
362
|
+
const createGit = async (directory, { allowUnsafeSshCommand = false, allowUnsafeCredentialHelper = false, stallTimeoutMs = 0 } = {}) => {
|
|
356
363
|
const env = await buildGitEnv();
|
|
357
364
|
const spawnOptions = { windowsHide: true };
|
|
365
|
+
// simple-git's block timeout kills the process once it has produced no
|
|
366
|
+
// output for this long. Opt-in per caller: a background read must never hold
|
|
367
|
+
// a limiter slot forever, while a silent long push or fetch must not be cut.
|
|
368
|
+
const timeout = stallTimeoutMs > 0 ? { block: stallTimeoutMs } : undefined;
|
|
358
369
|
const binary = getGitBinary();
|
|
359
370
|
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
|
|
360
371
|
const unsafe = hasCustomBinary || allowUnsafeSshCommand || allowUnsafeCredentialHelper
|
|
@@ -379,6 +390,7 @@ const createGit = async (directory, { allowUnsafeSshCommand = false, allowUnsafe
|
|
|
379
390
|
spawnOptions,
|
|
380
391
|
binary,
|
|
381
392
|
unsafe,
|
|
393
|
+
...(timeout ? { timeout } : {}),
|
|
382
394
|
});
|
|
383
395
|
};
|
|
384
396
|
|
|
@@ -484,14 +496,14 @@ const resolveGitRepositoryRoot = async (directoryPath, git) => {
|
|
|
484
496
|
: path.resolve(directoryPath, normalizedTopLevel);
|
|
485
497
|
};
|
|
486
498
|
|
|
487
|
-
const createRepositoryGitContext = async (directory) => {
|
|
499
|
+
const createRepositoryGitContext = async (directory, gitOptions = {}) => {
|
|
488
500
|
const directoryPath = normalizeDirectoryPath(directory);
|
|
489
501
|
if (typeof directoryPath !== 'string' || !directoryPath.trim()) {
|
|
490
502
|
throw new Error('Git directory is required');
|
|
491
503
|
}
|
|
492
|
-
const directoryGit = await createGit(directoryPath);
|
|
504
|
+
const directoryGit = await createGit(directoryPath, gitOptions);
|
|
493
505
|
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
|
494
|
-
const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot);
|
|
506
|
+
const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot, gitOptions);
|
|
495
507
|
return { directoryPath, directoryGit, repoRoot, git };
|
|
496
508
|
};
|
|
497
509
|
|
|
@@ -510,12 +522,46 @@ const resolveGitInternalPath = async (repoRoot, git, gitPath) => {
|
|
|
510
522
|
return path.resolve(repoRoot, resolved.trim());
|
|
511
523
|
};
|
|
512
524
|
|
|
525
|
+
const GITLINK_MODE = '160000';
|
|
526
|
+
|
|
527
|
+
// Paths from `git status` can stop resolving: the file was removed after the
|
|
528
|
+
// listing, or the entry is a nested repository git reports as `dir/`. Callers
|
|
529
|
+
// tell these apart by `code`, and diff routes send the code to clients as is.
|
|
530
|
+
const GIT_PATH_NOT_FOUND = 'path_not_found';
|
|
531
|
+
const GIT_PATH_IS_NESTED_REPOSITORY = 'nested_repository';
|
|
532
|
+
const GIT_PATH_IS_UNTRACKED_DIRECTORY = 'untracked_directory';
|
|
533
|
+
|
|
534
|
+
const GIT_PATH_ERROR_MESSAGES = {
|
|
535
|
+
[GIT_PATH_IS_NESTED_REPOSITORY]: (filePath) => `Path is a separate Git repository: ${filePath}`,
|
|
536
|
+
[GIT_PATH_IS_UNTRACKED_DIRECTORY]: (filePath) => `Path is a directory of untracked files: ${filePath}`,
|
|
537
|
+
[GIT_PATH_NOT_FOUND]: (filePath) => `Path not found in working tree, index, or HEAD: ${filePath}`,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const createGitPathError = (code, filePath) => Object.assign(new Error(GIT_PATH_ERROR_MESSAGES[code](filePath)), { code });
|
|
541
|
+
|
|
542
|
+
// Mode of the exact entry at `repoPath`, or null. `cat-file -e` cannot answer
|
|
543
|
+
// this: a gitlink's commit lives in the submodule's object store, so git exits 1
|
|
544
|
+
// without stderr, which simple-git reports as success.
|
|
545
|
+
const readGitEntryMode = async (repoRoot, args, repoPath) => {
|
|
546
|
+
const result = await runGitCommand(repoRoot, args);
|
|
547
|
+
if (!result.success) return null;
|
|
548
|
+
for (const record of result.stdout.split('\0')) {
|
|
549
|
+
const tab = record.indexOf('\t');
|
|
550
|
+
if (tab !== -1 && record.slice(tab + 1) === repoPath) {
|
|
551
|
+
return record.slice(0, record.indexOf(' '));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
};
|
|
556
|
+
|
|
513
557
|
const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverride = null) => {
|
|
514
558
|
const repoRoot = repoRootOverride || await resolveGitRepositoryRoot(directoryPath, git);
|
|
515
559
|
const candidates = Array.from(new Set([
|
|
516
560
|
path.resolve(repoRoot, filePath),
|
|
517
561
|
path.resolve(directoryPath, filePath),
|
|
518
562
|
]));
|
|
563
|
+
let nestedRepository = false;
|
|
564
|
+
let untrackedDirectory = false;
|
|
519
565
|
|
|
520
566
|
for (const absolutePath of candidates) {
|
|
521
567
|
if (!isInsideOrSameDirectory(repoRoot, absolutePath)) {
|
|
@@ -526,20 +572,68 @@ const resolveGitFileContext = async (directoryPath, git, filePath, repoRootOverr
|
|
|
526
572
|
const worktreeEntry = await fsp.lstat(absolutePath).catch(() => null);
|
|
527
573
|
const isSymbolicLink = worktreeEntry?.isSymbolicLink() ?? false;
|
|
528
574
|
const existsInWorktree = worktreeEntry?.isFile() || isSymbolicLink;
|
|
529
|
-
const
|
|
530
|
-
const
|
|
575
|
+
const indexMode = await readGitEntryMode(repoRoot, ['ls-files', '--stage', '-z', '--', `:(literal)${repoPath}`], repoPath);
|
|
576
|
+
const headMode = await readGitEntryMode(repoRoot, ['ls-tree', '-z', 'HEAD', '--', repoPath], repoPath);
|
|
531
577
|
|
|
532
|
-
if (existsInWorktree ||
|
|
578
|
+
if (existsInWorktree || indexMode || headMode) {
|
|
533
579
|
return {
|
|
534
580
|
absolutePath,
|
|
535
581
|
repoPath,
|
|
536
582
|
repoRoot,
|
|
537
583
|
isSymbolicLink,
|
|
584
|
+
isSubmodule: indexMode === GITLINK_MODE || headMode === GITLINK_MODE,
|
|
538
585
|
};
|
|
539
586
|
}
|
|
587
|
+
|
|
588
|
+
if (worktreeEntry?.isDirectory()) {
|
|
589
|
+
if (await fsp.lstat(path.join(absolutePath, '.git')).then(() => true, () => false)) {
|
|
590
|
+
nestedRepository = true;
|
|
591
|
+
} else {
|
|
592
|
+
// Status lists a directory whose untracked files were not expanded
|
|
593
|
+
// (see readStatus) as `dir/`; there is no single patch for it.
|
|
594
|
+
untrackedDirectory = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
540
597
|
}
|
|
541
598
|
|
|
542
|
-
throw
|
|
599
|
+
if (nestedRepository) throw createGitPathError(GIT_PATH_IS_NESTED_REPOSITORY, filePath);
|
|
600
|
+
if (untrackedDirectory) throw createGitPathError(GIT_PATH_IS_UNTRACKED_DIRECTORY, filePath);
|
|
601
|
+
throw createGitPathError(GIT_PATH_NOT_FOUND, filePath);
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* What a submodule entry records, since its text patch cannot show everything:
|
|
606
|
+
* with only untracked files inside, `git status` marks it modified while
|
|
607
|
+
* `git diff` prints nothing.
|
|
608
|
+
*/
|
|
609
|
+
const readSubmoduleState = async (repoRoot, fileContext) => {
|
|
610
|
+
const status = await runGitCommand(repoRoot, ['status', '--porcelain=v2', '-z', '--', `:(literal)${fileContext.repoPath}`]);
|
|
611
|
+
if (!status.success) {
|
|
612
|
+
throw new Error(status.message || 'Failed to read submodule status');
|
|
613
|
+
}
|
|
614
|
+
// Changed: "1 XY S<c><m><u> mH mI mW hH hI path" ("2" adds rename fields
|
|
615
|
+
// after hI). Unmerged: "u XY S<c><m><u> m1 m2 m3 mW h1 h2 h3 path", with no
|
|
616
|
+
// stage-0 index entry. A clean submodule has no record, so HEAD and the index
|
|
617
|
+
// record the same commit.
|
|
618
|
+
const record = status.stdout.split('\0').find((entry) => /^[12u] /.test(entry))?.split(' ');
|
|
619
|
+
const hasConflict = record?.[0] === 'u';
|
|
620
|
+
const readHead = async () => (await runGitCommand(repoRoot, ['rev-parse', '--verify', '--quiet', `HEAD:${fileContext.repoPath}`])).stdout.trim();
|
|
621
|
+
const head = record && !hasConflict ? record[6] : await readHead();
|
|
622
|
+
const index = hasConflict ? '' : (record ? record[7] : head);
|
|
623
|
+
const flags = record ? record[2] : 'S...';
|
|
624
|
+
// Without its own `.git`, rev-parse would answer for the parent repository.
|
|
625
|
+
const initialized = await fsp.lstat(path.join(fileContext.absolutePath, '.git')).then(() => true, () => false);
|
|
626
|
+
const worktree = initialized ? await runGitCommand(fileContext.absolutePath, ['rev-parse', '--verify', 'HEAD']) : null;
|
|
627
|
+
const commitOrNull = (value) => (value && !/^0+$/.test(value) ? value : null);
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
headCommit: commitOrNull(head),
|
|
631
|
+
indexCommit: commitOrNull(index),
|
|
632
|
+
worktreeCommit: worktree?.success ? worktree.stdout.trim() : null,
|
|
633
|
+
hasTrackedChanges: flags[2] === 'M',
|
|
634
|
+
hasUntrackedFiles: flags[3] === 'U',
|
|
635
|
+
hasConflict,
|
|
636
|
+
};
|
|
543
637
|
};
|
|
544
638
|
|
|
545
639
|
const cleanBranchName = (branch) => {
|
|
@@ -925,13 +1019,16 @@ const isMissingDirectoryError = (error) => {
|
|
|
925
1019
|
return /directory that does not exist|does not exist|no such file or directory/i.test(text);
|
|
926
1020
|
};
|
|
927
1021
|
|
|
928
|
-
const runGitCommand = async (cwd, args) => {
|
|
1022
|
+
const runGitCommand = async (cwd, args, { timeoutMs = 0 } = {}) => {
|
|
929
1023
|
try {
|
|
930
1024
|
const { stdout, stderr } = await execFileAsync(getGitBinary(), args, {
|
|
931
1025
|
cwd,
|
|
932
1026
|
env: await buildGitEnv(),
|
|
933
1027
|
windowsHide: true,
|
|
934
1028
|
maxBuffer: 20 * 1024 * 1024,
|
|
1029
|
+
// Only short probes pass a timeout; commands that legitimately run long
|
|
1030
|
+
// (a fetch into a temporary clone) keep the default of none.
|
|
1031
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs, killSignal: 'SIGKILL' } : {}),
|
|
935
1032
|
});
|
|
936
1033
|
return {
|
|
937
1034
|
success: true,
|
|
@@ -2058,14 +2155,45 @@ const applyUpstreamConfiguration = async (args) => {
|
|
|
2058
2155
|
);
|
|
2059
2156
|
};
|
|
2060
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* A repository whose root is the user's home directory or a filesystem root
|
|
2160
|
+
* (`C:\`, `/`) covers the whole disk. Every status read walks Program Files
|
|
2161
|
+
* or the entire home tree, which is minutes of Git work per refresh and, on
|
|
2162
|
+
* Windows, the process pile-ups users report. Such a repository is nearly
|
|
2163
|
+
* always an accidental `git init` in the wrong place, so OpenChamber treats
|
|
2164
|
+
* it as no repository at all. Returns the reason or null for a normal root.
|
|
2165
|
+
*/
|
|
2166
|
+
export const unsupportedRepositoryRootReason = (repoRoot, home = os.homedir()) => {
|
|
2167
|
+
if (typeof repoRoot !== 'string' || !repoRoot.trim()) return null;
|
|
2168
|
+
const resolved = path.resolve(repoRoot.trim());
|
|
2169
|
+
if (path.resolve(path.parse(resolved).root) === resolved) return 'filesystem-root';
|
|
2170
|
+
if (typeof home === 'string' && home.trim() && path.resolve(home.trim()) === resolved) return 'home';
|
|
2171
|
+
return null;
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
const warnedUnsupportedRoots = new Set();
|
|
2175
|
+
|
|
2061
2176
|
export async function isGitRepository(directory) {
|
|
2062
2177
|
const directoryPath = normalizeDirectoryPath(directory);
|
|
2063
2178
|
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
|
2064
2179
|
return false;
|
|
2065
2180
|
}
|
|
2066
2181
|
|
|
2067
|
-
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir']);
|
|
2068
|
-
|
|
2182
|
+
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-dir'], { timeoutMs: GIT_PROBE_TIMEOUT_MS });
|
|
2183
|
+
if (!result.success) return false;
|
|
2184
|
+
|
|
2185
|
+
// `--show-toplevel` has no answer inside a bare repository or a .git
|
|
2186
|
+
// directory; those keep the previous answer rather than being rejected.
|
|
2187
|
+
const topLevel = await runGitCommand(directoryPath, ['rev-parse', '--show-toplevel'], { timeoutMs: GIT_PROBE_TIMEOUT_MS });
|
|
2188
|
+
if (!topLevel.success) return true;
|
|
2189
|
+
const repoRoot = topLevel.stdout.trim();
|
|
2190
|
+
const reason = unsupportedRepositoryRootReason(repoRoot);
|
|
2191
|
+
if (!reason) return true;
|
|
2192
|
+
if (!warnedUnsupportedRoots.has(repoRoot)) {
|
|
2193
|
+
warnedUnsupportedRoots.add(repoRoot);
|
|
2194
|
+
console.warn(`[git] Ignoring repository rooted at ${repoRoot} (${reason}): Git features are disabled for ${directoryPath}`);
|
|
2195
|
+
}
|
|
2196
|
+
return false;
|
|
2069
2197
|
}
|
|
2070
2198
|
|
|
2071
2199
|
export async function getGlobalIdentity() {
|
|
@@ -2187,13 +2315,193 @@ export async function setLocalIdentity(directory, profile) {
|
|
|
2187
2315
|
}
|
|
2188
2316
|
}
|
|
2189
2317
|
|
|
2318
|
+
// Beyond this many untracked files, a directory stays one `dir/` entry in
|
|
2319
|
+
// status. Every file would otherwise become a row, a diff request, and a stat
|
|
2320
|
+
// on the server, and the only directories that large are ones that belong in
|
|
2321
|
+
// .gitignore.
|
|
2322
|
+
const UNTRACKED_DIRECTORY_EXPANSION_LIMIT = 1000;
|
|
2323
|
+
|
|
2324
|
+
// A status read holds one of MAX_CONCURRENT_STATUS_READS slots until it
|
|
2325
|
+
// finishes. Git never gets a terminal here, but a process can still hang on
|
|
2326
|
+
// Windows (a locked index, a stuck filesystem monitor, an unreachable network
|
|
2327
|
+
// drive), and a hung process would hold its slot forever: four of them and no
|
|
2328
|
+
// status read runs again until someone kills them by hand. Every process the
|
|
2329
|
+
// read spawns is therefore killed when it stops producing output for this long,
|
|
2330
|
+
// and the read fails instead of wedging the limiter. Two minutes is far above
|
|
2331
|
+
// what a healthy read spends silent, even on a very large tree.
|
|
2332
|
+
const GIT_STATUS_STALL_TIMEOUT_MS = 120_000;
|
|
2333
|
+
const GIT_UNTRACKED_LISTING_STALL_TIMEOUT_MS = 60_000;
|
|
2334
|
+
const GIT_PROBE_TIMEOUT_MS = 30_000;
|
|
2335
|
+
|
|
2336
|
+
// Untracked files under `dirPath` (repository-relative, trailing slash), read
|
|
2337
|
+
// Git for Windows runs commands through a launcher: the `git.exe` we spawn is a
|
|
2338
|
+
// wrapper whose child is the real `git`. Killing only the wrapper leaves that
|
|
2339
|
+
// child alive, still walking the tree on its own (a repository rooted at a
|
|
2340
|
+
// drive root sends it through Program Files), and it shows up in Task Manager
|
|
2341
|
+
// as a stuck pair until someone ends it by hand. Windows has no process groups
|
|
2342
|
+
// to signal, so the tree is ended through taskkill.
|
|
2343
|
+
const killProcessTree = (child) => {
|
|
2344
|
+
if (!child.pid) return;
|
|
2345
|
+
if (process.platform === 'win32') {
|
|
2346
|
+
try {
|
|
2347
|
+
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('error', () => {});
|
|
2348
|
+
} catch {
|
|
2349
|
+
child.kill('SIGKILL');
|
|
2350
|
+
}
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
child.kill('SIGKILL');
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2356
|
+
// from a streamed `ls-files` that is stopped once the bound is exceeded so a
|
|
2357
|
+
// huge directory is never listed in full. `paths` is complete when
|
|
2358
|
+
// `truncated` is false.
|
|
2359
|
+
const listUntrackedFilesBounded = async (repoRoot, dirPath, limit) => {
|
|
2360
|
+
const env = await buildGitEnv();
|
|
2361
|
+
return new Promise((resolve, reject) => {
|
|
2362
|
+
const child = spawn(getGitBinary(), ['ls-files', '--others', '--exclude-standard', '-z', '--', dirPath], {
|
|
2363
|
+
cwd: repoRoot,
|
|
2364
|
+
env,
|
|
2365
|
+
windowsHide: true,
|
|
2366
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2367
|
+
});
|
|
2368
|
+
const paths = [];
|
|
2369
|
+
let pending = '';
|
|
2370
|
+
let truncated = false;
|
|
2371
|
+
let settled = false;
|
|
2372
|
+
let stallTimer = null;
|
|
2373
|
+
const finish = (error) => {
|
|
2374
|
+
if (settled) return;
|
|
2375
|
+
settled = true;
|
|
2376
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
2377
|
+
if (error) {
|
|
2378
|
+
reject(error);
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
resolve({ paths, truncated });
|
|
2382
|
+
};
|
|
2383
|
+
// A listing that goes silent is killed rather than left holding the
|
|
2384
|
+
// status read (and its limiter slot) open.
|
|
2385
|
+
const armStallTimer = () => {
|
|
2386
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
2387
|
+
stallTimer = setTimeout(() => {
|
|
2388
|
+
killProcessTree(child);
|
|
2389
|
+
finish(new Error(`git ls-files produced no output for ${GIT_UNTRACKED_LISTING_STALL_TIMEOUT_MS}ms in ${dirPath}`));
|
|
2390
|
+
}, GIT_UNTRACKED_LISTING_STALL_TIMEOUT_MS);
|
|
2391
|
+
};
|
|
2392
|
+
armStallTimer();
|
|
2393
|
+
child.stdout.on('data', (chunk) => {
|
|
2394
|
+
armStallTimer();
|
|
2395
|
+
if (truncated) return;
|
|
2396
|
+
pending += chunk.toString('utf8');
|
|
2397
|
+
const records = pending.split('\0');
|
|
2398
|
+
pending = records.pop() ?? '';
|
|
2399
|
+
for (const record of records) {
|
|
2400
|
+
if (!record) continue;
|
|
2401
|
+
paths.push(record);
|
|
2402
|
+
if (paths.length > limit) {
|
|
2403
|
+
truncated = true;
|
|
2404
|
+
killProcessTree(child);
|
|
2405
|
+
finish();
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
child.on('error', (error) => finish(error));
|
|
2411
|
+
child.on('close', (code) => {
|
|
2412
|
+
if (truncated) {
|
|
2413
|
+
finish();
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
if (code !== 0) {
|
|
2417
|
+
finish(new Error(`git ls-files exited with code ${code} for ${dirPath}`));
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (pending) paths.push(pending);
|
|
2421
|
+
finish();
|
|
2422
|
+
});
|
|
2423
|
+
});
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
// Replaces each untracked `dir/` entry from `-unormal` with one entry per file
|
|
2427
|
+
// inside it, the listing `-uall` would have produced, unless the directory
|
|
2428
|
+
// holds more than the bound; then the `dir/` entry stays. A nested repository
|
|
2429
|
+
// lists as itself and stays a `dir/` entry too, which is what the diff routes
|
|
2430
|
+
// expect. A listing failure keeps the `dir/` entry rather than dropping the
|
|
2431
|
+
// change from the status.
|
|
2432
|
+
const expandUntrackedDirectories = async (repoRoot, files) => {
|
|
2433
|
+
const expanded = [];
|
|
2434
|
+
for (const file of files) {
|
|
2435
|
+
const isUntrackedDirectory = file.path.endsWith('/')
|
|
2436
|
+
&& (file.working_dir || '').trim() === '?'
|
|
2437
|
+
&& (file.index || '').trim() === '?';
|
|
2438
|
+
if (!isUntrackedDirectory) {
|
|
2439
|
+
expanded.push(file);
|
|
2440
|
+
continue;
|
|
2441
|
+
}
|
|
2442
|
+
const listing = await listUntrackedFilesBounded(repoRoot, file.path, UNTRACKED_DIRECTORY_EXPANSION_LIMIT)
|
|
2443
|
+
.catch((error) => {
|
|
2444
|
+
console.warn(`[GitService] Could not expand untracked directory ${file.path}:`, error?.message || error);
|
|
2445
|
+
return null;
|
|
2446
|
+
});
|
|
2447
|
+
if (!listing || listing.truncated || listing.paths.some((entry) => entry === file.path)) {
|
|
2448
|
+
expanded.push(file);
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
for (const entryPath of listing.paths) {
|
|
2452
|
+
expanded.push({ ...file, path: entryPath });
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return expanded;
|
|
2456
|
+
};
|
|
2457
|
+
|
|
2458
|
+
// A status read walks the working tree and runs a dozen Git processes; on a
|
|
2459
|
+
// large repository it takes seconds. Clients ask for it after every completed
|
|
2460
|
+
// agent tool call, from several surfaces, and from PR polling, so without a
|
|
2461
|
+
// bound one slow repository ends up with many identical `git status` processes
|
|
2462
|
+
// side by side. Runs are serialized per directory and capped across
|
|
2463
|
+
// directories; a caller that asks during a run gets a run started after it
|
|
2464
|
+
// asked, so results are never older than the request.
|
|
2465
|
+
const MAX_CONCURRENT_STATUS_READS = 4;
|
|
2466
|
+
const statusRefresh = createSerialRefresh({ maxConcurrent: MAX_CONCURRENT_STATUS_READS });
|
|
2467
|
+
|
|
2190
2468
|
export async function getStatus(directory, options = {}) {
|
|
2191
|
-
const lightMode = options.mode === 'light';
|
|
2192
2469
|
const normalizedDirectory = normalizeDirectoryPath(directory);
|
|
2193
2470
|
if (typeof normalizedDirectory !== 'string' || !normalizedDirectory.trim()) {
|
|
2194
2471
|
throw new Error('directory is required');
|
|
2195
2472
|
}
|
|
2473
|
+
const lightMode = options.mode === 'light';
|
|
2474
|
+
// A full read satisfies light callers too, so one run serves whichever
|
|
2475
|
+
// callers it answers, at the widest mode any of them asked for.
|
|
2476
|
+
return statusRefresh.run(
|
|
2477
|
+
normalizedDirectory,
|
|
2478
|
+
{ lightMode },
|
|
2479
|
+
(requests) => readStatus(normalizedDirectory, requests.every((request) => request.lightMode)),
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2196
2482
|
|
|
2483
|
+
/**
|
|
2484
|
+
* Upstream of the checked-out branch as `remote/branch`, or `null` when HEAD
|
|
2485
|
+
* is detached, unborn, or the branch has no upstream configured. Reads refs
|
|
2486
|
+
* and config only, never the working tree: callers that only need the
|
|
2487
|
+
* tracking name must not pay for a status read.
|
|
2488
|
+
*/
|
|
2489
|
+
export async function getTrackingBranch(directory) {
|
|
2490
|
+
const normalizedDirectory = normalizeDirectoryPath(directory);
|
|
2491
|
+
if (!normalizedDirectory) {
|
|
2492
|
+
return null;
|
|
2493
|
+
}
|
|
2494
|
+
const head = await runGitCommand(normalizedDirectory, ['symbolic-ref', '--quiet', 'HEAD']);
|
|
2495
|
+
const headRef = head.success ? head.stdout.trim() : '';
|
|
2496
|
+
if (!headRef.startsWith('refs/heads/')) {
|
|
2497
|
+
return null;
|
|
2498
|
+
}
|
|
2499
|
+
const upstream = await runGitCommand(normalizedDirectory, ['for-each-ref', '--format=%(upstream:short)', headRef]);
|
|
2500
|
+
const tracking = upstream.success ? upstream.stdout.trim() : '';
|
|
2501
|
+
return tracking || null;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
async function readStatus(normalizedDirectory, lightMode) {
|
|
2197
2505
|
try {
|
|
2198
2506
|
// Prefer an explicit non-repo check before simple-git status so a missing
|
|
2199
2507
|
// repository never depends on process.cwd() or an opaque GitError shape.
|
|
@@ -2201,12 +2509,22 @@ export async function getStatus(directory, options = {}) {
|
|
|
2201
2509
|
throw new Error('fatal: not a git repository (or any of the parent directories): .git');
|
|
2202
2510
|
}
|
|
2203
2511
|
|
|
2204
|
-
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
const status = await git.status(['-uall']);
|
|
2512
|
+
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory, {
|
|
2513
|
+
stallTimeoutMs: GIT_STATUS_STALL_TIMEOUT_MS,
|
|
2514
|
+
});
|
|
2208
2515
|
|
|
2209
|
-
//
|
|
2516
|
+
// `-unormal` lists a directory with no tracked files as one `dir/` entry
|
|
2517
|
+
// and stops walking it at its first file. `-uall` would walk every file
|
|
2518
|
+
// in it: on a forgotten build or dependency directory that is a scan of
|
|
2519
|
+
// tens of thousands of files and hundreds of megabytes per status read.
|
|
2520
|
+
// Directories are expanded to their files afterwards, up to a bound.
|
|
2521
|
+
const status = await git.status(['-unormal']);
|
|
2522
|
+
status.files = await expandUntrackedDirectories(repoRoot, status.files);
|
|
2523
|
+
|
|
2524
|
+
// Light mode: skip numstat + new-file line counting for faster response.
|
|
2525
|
+
// Staged (`--cached`: HEAD -> index) and working (`--numstat`: index -> worktree)
|
|
2526
|
+
// stay in separate maps. A partially staged file has an entry in both, and the
|
|
2527
|
+
// UI shows each row's own scope instead of a combined total.
|
|
2210
2528
|
const [stagedStatsRaw, workingStatsRaw] = lightMode
|
|
2211
2529
|
? ['', '']
|
|
2212
2530
|
: await Promise.all([
|
|
@@ -2214,9 +2532,10 @@ export async function getStatus(directory, options = {}) {
|
|
|
2214
2532
|
git.raw(['diff', '--numstat']).catch(() => ''),
|
|
2215
2533
|
]);
|
|
2216
2534
|
|
|
2217
|
-
const
|
|
2535
|
+
const stagedDiffStats = {};
|
|
2536
|
+
const workingDiffStats = {};
|
|
2218
2537
|
|
|
2219
|
-
const accumulateStats = (raw) => {
|
|
2538
|
+
const accumulateStats = (raw, target) => {
|
|
2220
2539
|
if (!raw) return;
|
|
2221
2540
|
raw
|
|
2222
2541
|
.split('\n')
|
|
@@ -2235,18 +2554,18 @@ export async function getStatus(directory, options = {}) {
|
|
|
2235
2554
|
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
|
2236
2555
|
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
|
2237
2556
|
|
|
2238
|
-
const existing =
|
|
2239
|
-
|
|
2557
|
+
const existing = target[path] || { insertions: 0, deletions: 0 };
|
|
2558
|
+
target[path] = {
|
|
2240
2559
|
insertions: existing.insertions + insertions,
|
|
2241
2560
|
deletions: existing.deletions + deletions,
|
|
2242
|
-
}
|
|
2561
|
+
};
|
|
2243
2562
|
});
|
|
2244
2563
|
};
|
|
2245
2564
|
|
|
2246
|
-
accumulateStats(stagedStatsRaw);
|
|
2247
|
-
accumulateStats(workingStatsRaw);
|
|
2565
|
+
accumulateStats(stagedStatsRaw, stagedDiffStats);
|
|
2566
|
+
accumulateStats(workingStatsRaw, workingDiffStats);
|
|
2248
2567
|
|
|
2249
|
-
const diffStats =
|
|
2568
|
+
const diffStats = { staged: stagedDiffStats, working: workingDiffStats };
|
|
2250
2569
|
|
|
2251
2570
|
const MAX_NEW_FILE_STATS = 200;
|
|
2252
2571
|
const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
|
|
@@ -2266,7 +2585,10 @@ export async function getStatus(directory, options = {}) {
|
|
|
2266
2585
|
continue;
|
|
2267
2586
|
}
|
|
2268
2587
|
|
|
2269
|
-
|
|
2588
|
+
// Untracked and working-tree-added files belong to the working scope;
|
|
2589
|
+
// a file whose 'A' code is on the index belongs to the staged scope.
|
|
2590
|
+
const target = working === '?' || working === 'A' ? workingDiffStats : stagedDiffStats;
|
|
2591
|
+
const existing = target[file.path];
|
|
2270
2592
|
if (existing && existing.insertions > 0) {
|
|
2271
2593
|
continue;
|
|
2272
2594
|
}
|
|
@@ -2282,6 +2604,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2282
2604
|
const buffer = await fsp.readFile(absolutePath);
|
|
2283
2605
|
if (buffer.indexOf(0) !== -1) {
|
|
2284
2606
|
newFileStats.push({
|
|
2607
|
+
target,
|
|
2285
2608
|
path: file.path,
|
|
2286
2609
|
insertions: existing?.insertions ?? 0,
|
|
2287
2610
|
deletions: existing?.deletions ?? 0,
|
|
@@ -2292,6 +2615,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2292
2615
|
const normalized = buffer.toString('utf8').replace(/\r\n/g, '\n');
|
|
2293
2616
|
if (!normalized.length) {
|
|
2294
2617
|
newFileStats.push({
|
|
2618
|
+
target,
|
|
2295
2619
|
path: file.path,
|
|
2296
2620
|
insertions: 0,
|
|
2297
2621
|
deletions: 0,
|
|
@@ -2306,6 +2630,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2306
2630
|
|
|
2307
2631
|
const lineCount = segments.length;
|
|
2308
2632
|
newFileStats.push({
|
|
2633
|
+
target,
|
|
2309
2634
|
path: file.path,
|
|
2310
2635
|
insertions: lineCount,
|
|
2311
2636
|
deletions: 0,
|
|
@@ -2319,7 +2644,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2319
2644
|
}
|
|
2320
2645
|
|
|
2321
2646
|
for (const entry of newFileStats) {
|
|
2322
|
-
|
|
2647
|
+
entry.target[entry.path] = {
|
|
2323
2648
|
insertions: entry.insertions,
|
|
2324
2649
|
deletions: entry.deletions,
|
|
2325
2650
|
};
|
|
@@ -2481,11 +2806,29 @@ const getNoIndexDiff = async (repoRoot, repoPath, contextLines) => {
|
|
|
2481
2806
|
};
|
|
2482
2807
|
|
|
2483
2808
|
export async function getDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
|
|
2484
|
-
const
|
|
2809
|
+
const context = await createRepositoryGitContext(directory);
|
|
2810
|
+
const fileContext = filePath
|
|
2811
|
+
? await resolveGitFileContext(context.directoryPath, context.directoryGit, filePath, context.repoRoot)
|
|
2812
|
+
: null;
|
|
2813
|
+
return readDiff(context, fileContext, { staged, contextLines });
|
|
2814
|
+
}
|
|
2485
2815
|
|
|
2816
|
+
/**
|
|
2817
|
+
* `getDiff` for one path, plus what a submodule records. A submodule patch is
|
|
2818
|
+
* empty when only untracked files changed inside it, so callers need the state
|
|
2819
|
+
* to show anything truthful.
|
|
2820
|
+
*/
|
|
2821
|
+
export async function getPathDiff(directory, { path: filePath, staged = false, contextLines = 3 } = {}) {
|
|
2822
|
+
const context = await createRepositoryGitContext(directory);
|
|
2823
|
+
const fileContext = await resolveGitFileContext(context.directoryPath, context.directoryGit, filePath, context.repoRoot);
|
|
2824
|
+
const diff = await readDiff(context, fileContext, { staged, contextLines });
|
|
2825
|
+
if (!fileContext.isSubmodule) return { diff, submodule: null };
|
|
2826
|
+
return { diff, submodule: await readSubmoduleState(context.repoRoot, fileContext) };
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
async function readDiff({ repoRoot, git }, fileContext, { staged, contextLines }) {
|
|
2486
2830
|
try {
|
|
2487
2831
|
const args = ['diff', '--no-color', '--full-index'];
|
|
2488
|
-
const fileContext = filePath ? await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot) : null;
|
|
2489
2832
|
|
|
2490
2833
|
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
|
2491
2834
|
args.push(`-U${Math.max(0, contextLines)}`);
|
|
@@ -2682,7 +3025,7 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
|
|
2682
3025
|
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
2683
3026
|
paths.push(fileContext.repoPath);
|
|
2684
3027
|
} catch (error) {
|
|
2685
|
-
if (error.
|
|
3028
|
+
if (error.code !== GIT_PATH_NOT_FOUND) throw error;
|
|
2686
3029
|
// A committed deletion is absent from HEAD, the index, and the working
|
|
2687
3030
|
// tree. It is still a valid range path when it exists at the merge base.
|
|
2688
3031
|
const mergeBase = (await git.raw(['merge-base', baseRef, headRef])).trim();
|
|
@@ -2907,7 +3250,22 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|
|
2907
3250
|
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
|
2908
3251
|
const isImage = isImageFile(filePath);
|
|
2909
3252
|
const mimeType = isImage ? getImageMimeType(filePath) : null;
|
|
2910
|
-
const
|
|
3253
|
+
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
|
3254
|
+
const { absolutePath, repoPath, isSymbolicLink } = fileContext;
|
|
3255
|
+
|
|
3256
|
+
if (fileContext.isSubmodule) {
|
|
3257
|
+
// Git's own text form of a gitlink, so a plain two-pane view still shows
|
|
3258
|
+
// the recorded commits; `submodule` carries what the text cannot.
|
|
3259
|
+
const submodule = await readSubmoduleState(repoRoot, fileContext);
|
|
3260
|
+
const describeCommit = (commit) => (commit ? `Subproject commit ${commit}\n` : '');
|
|
3261
|
+
return {
|
|
3262
|
+
original: describeCommit(submodule.headCommit),
|
|
3263
|
+
modified: describeCommit(staged ? submodule.indexCommit : submodule.worktreeCommit),
|
|
3264
|
+
path: filePath,
|
|
3265
|
+
isBinary: false,
|
|
3266
|
+
submodule,
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
2911
3269
|
|
|
2912
3270
|
if (!isImage && !isSymbolicLink) {
|
|
2913
3271
|
const isBinaryBySniff = await looksBinaryBySniff(absolutePath);
|
|
@@ -3346,26 +3704,45 @@ export async function push(directory, options = {}) {
|
|
|
3346
3704
|
);
|
|
3347
3705
|
};
|
|
3348
3706
|
|
|
3349
|
-
const
|
|
3707
|
+
const remote = String(options.remote || '').trim();
|
|
3708
|
+
const status = await git.status();
|
|
3709
|
+
const config = await git.listConfig();
|
|
3710
|
+
const remotes = await git.getRemotes(true);
|
|
3711
|
+
const remoteName = remote
|
|
3712
|
+
|| config.all[`branch.${status.current}.pushremote`]
|
|
3713
|
+
|| config.all['remote.pushdefault']
|
|
3714
|
+
|| config.all[`branch.${status.current}.remote`]
|
|
3715
|
+
|| (remotes.length === 1 ? remotes[0].name : 'origin');
|
|
3716
|
+
|
|
3717
|
+
const pushTo = async (target, branch, pushOptions) => {
|
|
3718
|
+
// simple-git drops forced updates and puts no-ops in `pushed`. Read Git's
|
|
3719
|
+
// porcelain status flags so feedback reflects actual remote ref changes.
|
|
3720
|
+
let output = '';
|
|
3721
|
+
git.outputHandler((_command, stdout) => {
|
|
3722
|
+
stdout.on('data', (chunk) => { output += chunk.toString(); });
|
|
3723
|
+
});
|
|
3724
|
+
const result = await git.push(target, branch, pushOptions);
|
|
3725
|
+
const pushed = [];
|
|
3726
|
+
for (const line of output.split(/\r?\n/)) {
|
|
3727
|
+
const match = /^([ *+\-])\t([^:]*):([^\t]+)\t/.exec(line);
|
|
3728
|
+
if (match) {
|
|
3729
|
+
pushed.push({ local: match[2], remote: remoteName });
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3350
3732
|
return {
|
|
3351
3733
|
success: true,
|
|
3352
|
-
pushed
|
|
3353
|
-
repo: result.repo,
|
|
3354
|
-
ref: result.ref,
|
|
3734
|
+
pushed,
|
|
3735
|
+
repo: result.repo || directory,
|
|
3736
|
+
ref: result.ref || null,
|
|
3355
3737
|
};
|
|
3356
3738
|
};
|
|
3357
3739
|
|
|
3358
|
-
const remote = String(options.remote || '').trim();
|
|
3359
|
-
|
|
3360
3740
|
if (!remote && !options.branch) {
|
|
3361
3741
|
try {
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
repo: directory,
|
|
3367
|
-
ref: null,
|
|
3368
|
-
};
|
|
3742
|
+
const pushOptions = status.current && !status.tracking
|
|
3743
|
+
? buildUpstreamOptions(options.options)
|
|
3744
|
+
: options.options || {};
|
|
3745
|
+
return await pushTo(undefined, undefined, pushOptions);
|
|
3369
3746
|
} catch (error) {
|
|
3370
3747
|
if (!looksLikeMissingUpstream(error)) {
|
|
3371
3748
|
const message = describePushError(error);
|
|
@@ -3374,17 +3751,13 @@ export async function push(directory, options = {}) {
|
|
|
3374
3751
|
}
|
|
3375
3752
|
|
|
3376
3753
|
try {
|
|
3377
|
-
const status = await git.status();
|
|
3378
3754
|
const branch = status.current;
|
|
3379
|
-
|
|
3380
|
-
const fallbackRemote = remotes.find((entry) => entry.name === 'origin')?.name || remotes[0]?.name;
|
|
3381
|
-
if (!branch || !fallbackRemote) {
|
|
3755
|
+
if (!branch || !remoteName) {
|
|
3382
3756
|
const message = describePushError(error);
|
|
3383
3757
|
throw new Error(message);
|
|
3384
3758
|
}
|
|
3385
3759
|
|
|
3386
|
-
|
|
3387
|
-
return normalizePushResult(result);
|
|
3760
|
+
return await pushTo(remoteName, branch, buildUpstreamOptions(options.options));
|
|
3388
3761
|
} catch (fallbackError) {
|
|
3389
3762
|
const message = describePushError(fallbackError);
|
|
3390
3763
|
console.error('Failed to push (including upstream fallback):', fallbackError);
|
|
@@ -3393,26 +3766,14 @@ export async function push(directory, options = {}) {
|
|
|
3393
3766
|
}
|
|
3394
3767
|
}
|
|
3395
3768
|
|
|
3396
|
-
const remoteName = remote || 'origin';
|
|
3397
|
-
|
|
3398
3769
|
// If caller didn't specify a branch, this is the common "Push"/"Commit & Push" path.
|
|
3399
3770
|
// When there's no upstream yet (typical for freshly-created worktree branches), publish it on first push.
|
|
3400
|
-
if (!options.branch) {
|
|
3401
|
-
|
|
3402
|
-
const status = await git.status();
|
|
3403
|
-
if (status.current && !status.tracking) {
|
|
3404
|
-
const result = await git.push(remoteName, status.current, buildUpstreamOptions(options.options));
|
|
3405
|
-
return normalizePushResult(result);
|
|
3406
|
-
}
|
|
3407
|
-
} catch (error) {
|
|
3408
|
-
// If we can't read status, fall back to the regular push path below.
|
|
3409
|
-
console.warn('Failed to read git status before push:', error);
|
|
3410
|
-
}
|
|
3771
|
+
if (!options.branch && status.current && !status.tracking) {
|
|
3772
|
+
return pushTo(remoteName, status.current, buildUpstreamOptions(options.options));
|
|
3411
3773
|
}
|
|
3412
3774
|
|
|
3413
3775
|
try {
|
|
3414
|
-
|
|
3415
|
-
return normalizePushResult(result);
|
|
3776
|
+
return await pushTo(remoteName, options.branch, options.options || {});
|
|
3416
3777
|
} catch (error) {
|
|
3417
3778
|
// Last-resort fallback: retry with upstream if the error suggests it's missing.
|
|
3418
3779
|
if (!looksLikeMissingUpstream(error)) {
|
|
@@ -3422,15 +3783,13 @@ export async function push(directory, options = {}) {
|
|
|
3422
3783
|
}
|
|
3423
3784
|
|
|
3424
3785
|
try {
|
|
3425
|
-
const status = await git.status();
|
|
3426
3786
|
const branch = options.branch || status.current;
|
|
3427
3787
|
if (!branch) {
|
|
3428
3788
|
console.error('Failed to push: missing branch name for upstream setup:', error);
|
|
3429
3789
|
throw error;
|
|
3430
3790
|
}
|
|
3431
3791
|
|
|
3432
|
-
|
|
3433
|
-
return normalizePushResult(result);
|
|
3792
|
+
return await pushTo(remoteName, branch, buildUpstreamOptions(options.options));
|
|
3434
3793
|
} catch (fallbackError) {
|
|
3435
3794
|
const message = describePushError(fallbackError);
|
|
3436
3795
|
console.error('Failed to push (including upstream fallback):', fallbackError);
|
|
@@ -4064,14 +4423,167 @@ export async function getWorktrees(directory) {
|
|
|
4064
4423
|
// OpenCode's working directory or an unconfigured project path), git
|
|
4065
4424
|
// exits with "fatal: not a git repository ...". Treat that as an
|
|
4066
4425
|
// authoritative empty result so the route handler can still respond
|
|
4067
|
-
// 200 [] and the desktop main.log stays free of noise.
|
|
4068
|
-
|
|
4069
|
-
|
|
4426
|
+
// 200 [] and the desktop main.log stays free of noise. Any other failure
|
|
4427
|
+
// is a failure: callers keep their last known topology instead of
|
|
4428
|
+
// treating "git could not answer" as "there are no worktrees".
|
|
4429
|
+
if (isNotGitRepositoryError(error)) return [];
|
|
4430
|
+
throw error;
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
// ---------------------------------------------------------------------------
|
|
4435
|
+
// Worktree topology change tracking
|
|
4436
|
+
//
|
|
4437
|
+
// Linked worktrees are registered under the repository's common Git directory
|
|
4438
|
+
// (`<common>/worktrees/<name>`). Instead of watching the filesystem, the server
|
|
4439
|
+
// fingerprints that directory while handling requests clients already make
|
|
4440
|
+
// (status, worktree listing) and after its own worktree create/remove, and
|
|
4441
|
+
// tells connected clients when the set of worktrees changed. Cost scales with
|
|
4442
|
+
// user activity, never with the number of registered projects.
|
|
4443
|
+
// ---------------------------------------------------------------------------
|
|
4444
|
+
|
|
4445
|
+
const MAX_TRACKED_WORKTREE_DIRECTORIES = 500;
|
|
4446
|
+
const MAX_TRACKED_WORKTREE_REPOSITORIES = 200;
|
|
4447
|
+
const MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY = 100;
|
|
4448
|
+
const worktreeTopologyListeners = new Set();
|
|
4449
|
+
const worktreeRepositoryKeyByDirectory = new Map();
|
|
4450
|
+
const worktreeTopologyByRepository = new Map();
|
|
4451
|
+
|
|
4452
|
+
export function subscribeWorktreeTopologyChanges(listener) {
|
|
4453
|
+
worktreeTopologyListeners.add(listener);
|
|
4454
|
+
return () => {
|
|
4455
|
+
worktreeTopologyListeners.delete(listener);
|
|
4456
|
+
};
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
const rememberWorktreeRepositoryKey = (directoryPath, key) => {
|
|
4460
|
+
worktreeRepositoryKeyByDirectory.delete(directoryPath);
|
|
4461
|
+
worktreeRepositoryKeyByDirectory.set(directoryPath, key);
|
|
4462
|
+
while (worktreeRepositoryKeyByDirectory.size > MAX_TRACKED_WORKTREE_DIRECTORIES) {
|
|
4463
|
+
const oldest = worktreeRepositoryKeyByDirectory.keys().next().value;
|
|
4464
|
+
if (oldest === undefined) break;
|
|
4465
|
+
worktreeRepositoryKeyByDirectory.delete(oldest);
|
|
4466
|
+
}
|
|
4467
|
+
};
|
|
4468
|
+
|
|
4469
|
+
/**
|
|
4470
|
+
* Canonical common Git directory for `directoryPath`, resolved with git once per
|
|
4471
|
+
* directory and cached. Returns null when git cannot answer (not a repository,
|
|
4472
|
+
* missing directory).
|
|
4473
|
+
*/
|
|
4474
|
+
const resolveWorktreeRepositoryKey = async (directoryPath) => {
|
|
4475
|
+
const cached = worktreeRepositoryKeyByDirectory.get(directoryPath);
|
|
4476
|
+
if (cached) {
|
|
4477
|
+
rememberWorktreeRepositoryKey(directoryPath, cached);
|
|
4478
|
+
return cached;
|
|
4479
|
+
}
|
|
4480
|
+
const result = await runGitCommand(directoryPath, ['rev-parse', '--git-common-dir']);
|
|
4481
|
+
const rawCommonDir = String(result.stdout || '').trim();
|
|
4482
|
+
if (!result.success || !rawCommonDir) {
|
|
4483
|
+
return null;
|
|
4484
|
+
}
|
|
4485
|
+
const commonDir = path.resolve(directoryPath, rawCommonDir);
|
|
4486
|
+
let key = commonDir;
|
|
4487
|
+
try {
|
|
4488
|
+
key = fs.realpathSync(commonDir);
|
|
4489
|
+
} catch {
|
|
4490
|
+
// Keep the resolved path; a missing common dir cannot register worktrees.
|
|
4491
|
+
}
|
|
4492
|
+
rememberWorktreeRepositoryKey(directoryPath, key);
|
|
4493
|
+
return key;
|
|
4494
|
+
};
|
|
4495
|
+
|
|
4496
|
+
/**
|
|
4497
|
+
* Cheap identity of the registered linked-worktree set: the `worktrees`
|
|
4498
|
+
* directory's mtime plus its entry names. Adding, removing, or pruning a
|
|
4499
|
+
* worktree changes at least one of them; `git worktree move` rewrites files
|
|
4500
|
+
* inside an entry and is not detected.
|
|
4501
|
+
*/
|
|
4502
|
+
const readWorktreeTopologyFingerprint = (repositoryKey) => {
|
|
4503
|
+
const worktreesDir = path.join(repositoryKey, 'worktrees');
|
|
4504
|
+
try {
|
|
4505
|
+
const stat = fs.statSync(worktreesDir);
|
|
4506
|
+
const names = fs.readdirSync(worktreesDir).sort();
|
|
4507
|
+
return `${stat.mtimeMs}:${names.join('\0')}`;
|
|
4508
|
+
} catch {
|
|
4509
|
+
return 'none';
|
|
4510
|
+
}
|
|
4511
|
+
};
|
|
4512
|
+
|
|
4513
|
+
const trackWorktreeTopologyDirectory = (repositoryKey, directoryPath) => {
|
|
4514
|
+
let entry = worktreeTopologyByRepository.get(repositoryKey);
|
|
4515
|
+
if (!entry) {
|
|
4516
|
+
entry = { directories: new Set(), fingerprint: null };
|
|
4517
|
+
}
|
|
4518
|
+
// Re-insert so the map stays ordered by last use; the least recently used
|
|
4519
|
+
// repository is dropped first once the bound is reached.
|
|
4520
|
+
worktreeTopologyByRepository.delete(repositoryKey);
|
|
4521
|
+
worktreeTopologyByRepository.set(repositoryKey, entry);
|
|
4522
|
+
while (worktreeTopologyByRepository.size > MAX_TRACKED_WORKTREE_REPOSITORIES) {
|
|
4523
|
+
const oldest = worktreeTopologyByRepository.keys().next().value;
|
|
4524
|
+
if (oldest === undefined) break;
|
|
4525
|
+
worktreeTopologyByRepository.delete(oldest);
|
|
4526
|
+
}
|
|
4527
|
+
if (entry.directories.size < MAX_DIRECTORIES_PER_WORKTREE_REPOSITORY) {
|
|
4528
|
+
entry.directories.add(directoryPath);
|
|
4529
|
+
}
|
|
4530
|
+
return entry;
|
|
4531
|
+
};
|
|
4532
|
+
|
|
4533
|
+
const notifyWorktreeTopologyChanged = (entry) => {
|
|
4534
|
+
const event = { directories: [...entry.directories], at: Date.now() };
|
|
4535
|
+
for (const listener of worktreeTopologyListeners) {
|
|
4536
|
+
try {
|
|
4537
|
+
listener(event);
|
|
4538
|
+
} catch (error) {
|
|
4539
|
+
console.warn('Worktree topology listener failed:', error?.message || error);
|
|
4070
4540
|
}
|
|
4071
|
-
|
|
4541
|
+
}
|
|
4542
|
+
};
|
|
4543
|
+
|
|
4544
|
+
/**
|
|
4545
|
+
* Compare the repository's worktree set with the last one seen for it and
|
|
4546
|
+
* notify listeners when it changed. The first observation only records a
|
|
4547
|
+
* baseline. Called from request handlers that already touch the repository;
|
|
4548
|
+
* never throws.
|
|
4549
|
+
*/
|
|
4550
|
+
export async function observeWorktreeTopology(directory) {
|
|
4551
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
4552
|
+
if (!directoryPath) return;
|
|
4553
|
+
try {
|
|
4554
|
+
const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
|
|
4555
|
+
if (!repositoryKey) return;
|
|
4556
|
+
const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
|
|
4557
|
+
const fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
|
|
4558
|
+
if (entry.fingerprint === fingerprint) return;
|
|
4559
|
+
const hadBaseline = entry.fingerprint !== null;
|
|
4560
|
+
entry.fingerprint = fingerprint;
|
|
4561
|
+
if (hadBaseline) notifyWorktreeTopologyChanged(entry);
|
|
4562
|
+
} catch (error) {
|
|
4563
|
+
console.warn('Failed to observe worktree topology:', error?.message || error);
|
|
4072
4564
|
}
|
|
4073
4565
|
}
|
|
4074
4566
|
|
|
4567
|
+
/**
|
|
4568
|
+
* Record that this server changed the repository's worktree set itself and
|
|
4569
|
+
* notify listeners right away. `directory` is any directory inside the
|
|
4570
|
+
* repository; never throws so a notification problem cannot fail the
|
|
4571
|
+
* operation that triggered it.
|
|
4572
|
+
*/
|
|
4573
|
+
const publishWorktreeTopologyChange = async (directory) => {
|
|
4574
|
+
const directoryPath = normalizeDirectoryPath(directory);
|
|
4575
|
+
if (!directoryPath) return;
|
|
4576
|
+
try {
|
|
4577
|
+
const repositoryKey = await resolveWorktreeRepositoryKey(directoryPath);
|
|
4578
|
+
if (!repositoryKey) return;
|
|
4579
|
+
const entry = trackWorktreeTopologyDirectory(repositoryKey, directoryPath);
|
|
4580
|
+
entry.fingerprint = readWorktreeTopologyFingerprint(repositoryKey);
|
|
4581
|
+
notifyWorktreeTopologyChanged(entry);
|
|
4582
|
+
} catch (error) {
|
|
4583
|
+
console.warn('Failed to publish worktree topology change:', error?.message || error);
|
|
4584
|
+
}
|
|
4585
|
+
};
|
|
4586
|
+
|
|
4075
4587
|
export async function validateWorktreeCreate(directory, input = {}) {
|
|
4076
4588
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
|
4077
4589
|
const errors = [];
|
|
@@ -4329,6 +4841,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
|
|
4329
4841
|
}
|
|
4330
4842
|
|
|
4331
4843
|
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
|
4844
|
+
await publishWorktreeTopologyChange(context.primaryWorktree);
|
|
4332
4845
|
|
|
4333
4846
|
const upstreamRemote = shouldSetUpstream
|
|
4334
4847
|
? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim()
|
|
@@ -4551,6 +5064,7 @@ export async function removeWorktree(directory, input = {}) {
|
|
|
4551
5064
|
['worktree', 'remove', '--force', matchedEntry.worktree],
|
|
4552
5065
|
'Failed to remove git worktree'
|
|
4553
5066
|
);
|
|
5067
|
+
await publishWorktreeTopologyChange(context.primaryWorktree);
|
|
4554
5068
|
|
|
4555
5069
|
if (deleteLocalBranch) {
|
|
4556
5070
|
const branchName = cleanBranchName(String(matchedEntry.branchRef || matchedEntry.branch || '').trim());
|
|
@@ -4902,7 +5416,7 @@ export async function canonicalizeWorktreeState(directory) {
|
|
|
4902
5416
|
|
|
4903
5417
|
// Detect attention reasons from getStatus side-effects
|
|
4904
5418
|
try {
|
|
4905
|
-
const status = await git.status(['-
|
|
5419
|
+
const status = await git.status(['-unormal']);
|
|
4906
5420
|
if (status.current && (await git.raw(['rev-parse', '--verify', 'MERGE_HEAD']).then(() => true).catch(() => false))) {
|
|
4907
5421
|
attentionReason = 'merge';
|
|
4908
5422
|
} else {
|