termdock 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/dist/client/apple-splash-default-1170x2532.png +0 -0
- package/dist/client/apple-splash-landscape-1136x640.png +0 -0
- package/dist/client/apple-splash-landscape-1334x750.png +0 -0
- package/dist/client/apple-splash-landscape-1792x828.png +0 -0
- package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
- package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
- package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
- package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
- package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
- package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
- package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
- package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
- package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
- package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
- package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
- package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
- package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
- package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
- package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
- package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
- package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
- package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
- package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
- package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
- package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
- package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
- package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
- package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
- package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
- package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
- package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
- package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
- package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
- package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
- package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
- package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
- package/dist/client/apple-splash-portrait-640x1136.png +0 -0
- package/dist/client/apple-splash-portrait-750x1334.png +0 -0
- package/dist/client/apple-splash-portrait-828x1792.png +0 -0
- package/dist/client/apple-touch-icon-180x180.png +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
- package/dist/client/assets/arc-B7GoKc6u.js +1 -0
- package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
- package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
- package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
- package/dist/client/assets/channel-C2gUFcyU.js +1 -0
- package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
- package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
- package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
- package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
- package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
- package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
- package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
- package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
- package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
- package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
- package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
- package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
- package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
- package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
- package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
- package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
- package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
- package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
- package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
- package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
- package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
- package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
- package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
- package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
- package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
- package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
- package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
- package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
- package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
- package/dist/client/assets/graph-D2o_JWn5.js +1 -0
- package/dist/client/assets/index-Ca-rq7yX.js +6 -0
- package/dist/client/assets/index-Cv7q4nZR.css +1 -0
- package/dist/client/assets/index-XktPMm51.js +394 -0
- package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
- package/dist/client/assets/init-Gi6I4Gst.js +1 -0
- package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
- package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
- package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
- package/dist/client/assets/katex-55ZPZeOF.css +1 -0
- package/dist/client/assets/katex-DnlPpQZa.js +257 -0
- package/dist/client/assets/katex-HP8lGamR.js +257 -0
- package/dist/client/assets/layout-1eBIAoSl.js +1 -0
- package/dist/client/assets/linear-jlTiHqO6.js +1 -0
- package/dist/client/assets/map-BdqsVdb3.js +1 -0
- package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
- package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
- package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
- package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
- package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
- package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
- package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
- package/dist/client/assets/react-XDCjal2T.js +8 -0
- package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
- package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
- package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
- package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
- package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
- package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
- package/dist/client/assets/terminal-C7X98Amf.js +10 -0
- package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
- package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
- package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
- package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
- package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
- package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
- package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
- package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
- package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
- package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
- package/dist/client/index.html +255 -0
- package/dist/client/manifest.webmanifest +1 -0
- package/dist/client/maskable-icon-512x512.png +0 -0
- package/dist/client/pwa-192x192.png +0 -0
- package/dist/client/pwa-192x192.svg +5 -0
- package/dist/client/pwa-512x512.png +0 -0
- package/dist/client/pwa-512x512.svg +5 -0
- package/dist/client/pwa-64x64.png +0 -0
- package/dist/client/registerSW.js +1 -0
- package/dist/client/robots.txt +4 -0
- package/dist/client/shell-integration/termdock.bash +107 -0
- package/dist/client/shell-integration/termdock.fish +60 -0
- package/dist/client/shell-integration/termdock.zsh +164 -0
- package/dist/client/sw-notifications.js +42 -0
- package/dist/client/sw.js +1 -0
- package/dist/client/workbox-e4022e15.js +1 -0
- package/dist/server/certificateWatcher.js +111 -0
- package/dist/server/cli.js +2534 -0
- package/dist/server/config.js +54 -0
- package/dist/server/entry.js +571 -0
- package/dist/server/onboardingServer.js +54 -0
- package/dist/server/routes/auth.js +48 -0
- package/dist/server/routes/filesystem.js +3749 -0
- package/dist/server/routes/local.js +32 -0
- package/dist/server/routes/onboarding.js +134 -0
- package/dist/server/routes/terminal.js +4879 -0
- package/dist/server/utils/authProtection.js +338 -0
- package/dist/server/utils/caffeinate.js +124 -0
- package/dist/server/utils/changeAuditStore.js +521 -0
- package/dist/server/utils/cookieSecurity.js +13 -0
- package/dist/server/utils/csrfProtection.js +112 -0
- package/dist/server/utils/localAccess.js +211 -0
- package/dist/server/utils/pathValidator.js +272 -0
- package/dist/server/utils/requestSecurity.js +103 -0
- package/dist/server/utils/serverLogger.js +57 -0
- package/dist/server/utils/settings.js +157 -0
- package/dist/server/utils/tmuxFocus.js +45 -0
- package/dist/server/utils/tmuxProgramDetection.js +148 -0
- package/package.json +125 -0
|
@@ -0,0 +1,3749 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execFile, spawn } from 'child_process';
|
|
5
|
+
import watcher from '@parcel/watcher';
|
|
6
|
+
import busboy from 'busboy';
|
|
7
|
+
import { pathValidator } from '../utils/pathValidator.js';
|
|
8
|
+
import { writeDiffTraceLog, writeErrorLog, writeJsonLog } from '../utils/serverLogger.js';
|
|
9
|
+
import { clearBranchAuditRecords, clearChangeAuditRecords, listBranchAuditRecords, listChangeAuditRecords, buildChangeAuditFingerprint } from '../utils/changeAuditStore.js';
|
|
10
|
+
import { getLanIPv4Addresses } from '../utils/localAccess.js';
|
|
11
|
+
const router = Router();
|
|
12
|
+
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
|
13
|
+
const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
|
|
14
|
+
const MAX_DOWNLOAD_SIZE = 200 * 1024 * 1024; // 200MB
|
|
15
|
+
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; // 100MB per file
|
|
16
|
+
const MAX_UPLOAD_FILES = 50; // max 50 files per upload
|
|
17
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
18
|
+
const GIT_UNTRACKED_TIMEOUT_MS = 800;
|
|
19
|
+
const GIT_BUNDLE_CACHE_TTL_MS = 60_000;
|
|
20
|
+
const MAX_DIRECTORY_ENTRIES = 1000;
|
|
21
|
+
const MAX_FALLBACK_SEARCH_VISITED = 30_000;
|
|
22
|
+
// Content (full-text) search caps so a broad query can't flood the stream/UI.
|
|
23
|
+
const MAX_CONTENT_SEARCH_FILES = 1_000;
|
|
24
|
+
const MAX_CONTENT_MATCHES_PER_FILE = 50;
|
|
25
|
+
const MAX_CONTENT_MATCH_LINE_LENGTH = 400;
|
|
26
|
+
const MAX_GIT_CONTEXT_CHANGED_FILES = 200;
|
|
27
|
+
const MAX_RECENT_COMMITS_LIMIT = 50;
|
|
28
|
+
const MAX_DIFF_BYTES = 1024 * 1024; // 1MB
|
|
29
|
+
const MAX_BRANCH_DIFF_BYTES = 2 * 1024 * 1024; // 2MB
|
|
30
|
+
const MAX_BRANCH_DIFF_STAT_BYTES = 128 * 1024;
|
|
31
|
+
const MAX_BRANCH_DIFF_NAME_BYTES = 256 * 1024;
|
|
32
|
+
const MAX_BRANCH_DIFF_LOG_BYTES = 256 * 1024;
|
|
33
|
+
const MAX_UNTRACKED_DIFF_FILE_BYTES = 1024 * 1024; // 1MB
|
|
34
|
+
const MAX_NESTED_GIT_REPOS = 32;
|
|
35
|
+
const NESTED_GIT_DISCOVERY_TIMEOUT_MS = 1_000;
|
|
36
|
+
const NESTED_GIT_DISCOVERY_CACHE_TTL_MS = 60_000;
|
|
37
|
+
const FS_ROUTE_TIMEOUT_MS = 6_000;
|
|
38
|
+
const GIT_ROUTE_TIMEOUT_MS = 8_000;
|
|
39
|
+
const GIT_FILE_DIFF_ROUTE_TIMEOUT_MS = 45_000;
|
|
40
|
+
const GIT_ACTION_TIMEOUT_MS = 10 * 60_000;
|
|
41
|
+
const RESTORE_CONFIRM_PHRASES = new Set(['丢弃改动', 'discard changes']);
|
|
42
|
+
const FS_IO_LOG_NAME = 'fs-io.log';
|
|
43
|
+
const activeDiffSlots = new Map();
|
|
44
|
+
const activeIoSlots = new Map();
|
|
45
|
+
const untrackedJobs = new Map();
|
|
46
|
+
const nestedGitRootsCache = new Map();
|
|
47
|
+
const gitActionJobs = new Map();
|
|
48
|
+
let gitActionJobSeq = 0;
|
|
49
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
50
|
+
let fsIoRequestSeq = 0;
|
|
51
|
+
const fsIoInflightByOp = new Map();
|
|
52
|
+
class OperationTimeoutError extends Error {
|
|
53
|
+
code;
|
|
54
|
+
constructor(message, code = 'OPERATION_TIMEOUT') {
|
|
55
|
+
super(message);
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.name = 'OperationTimeoutError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
class GitCommandAbortError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
constructor(message = 'git command aborted', code = 'GIT_COMMAND_ABORTED') {
|
|
63
|
+
super(message);
|
|
64
|
+
this.code = code;
|
|
65
|
+
this.name = 'GitCommandAbortError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
class SupersededRequestError extends Error {
|
|
69
|
+
op;
|
|
70
|
+
constructor(op) {
|
|
71
|
+
super(`${op} request was cancelled because a newer request replaced it.`);
|
|
72
|
+
this.op = op;
|
|
73
|
+
this.name = 'SupersededRequestError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function withTimeout(promise, timeoutMs, message, code, onTimeout) {
|
|
77
|
+
let timer = null;
|
|
78
|
+
const timeout = new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
const error = new OperationTimeoutError(message, code);
|
|
81
|
+
onTimeout?.();
|
|
82
|
+
reject(error);
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
});
|
|
85
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
86
|
+
if (timer)
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function getErrorPayload(error, fallback = 'Unknown error') {
|
|
91
|
+
if (error instanceof OperationTimeoutError) {
|
|
92
|
+
return { error: error.message, code: error.code };
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof SupersededRequestError) {
|
|
95
|
+
return { error: error.message, code: 'IO_REQUEST_CANCELLED' };
|
|
96
|
+
}
|
|
97
|
+
if (error instanceof GitCommandAbortError) {
|
|
98
|
+
return { error: error.message, code: error.code };
|
|
99
|
+
}
|
|
100
|
+
return { error: error instanceof Error ? error.message : fallback };
|
|
101
|
+
}
|
|
102
|
+
function truncateLogValue(value, maxLength = 500) {
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
|
|
106
|
+
}
|
|
107
|
+
function writeFsIoLog(entry) {
|
|
108
|
+
writeJsonLog(FS_IO_LOG_NAME, entry);
|
|
109
|
+
}
|
|
110
|
+
function updateInflight(op, delta) {
|
|
111
|
+
const next = Math.max(0, (fsIoInflightByOp.get(op) ?? 0) + delta);
|
|
112
|
+
if (next === 0)
|
|
113
|
+
fsIoInflightByOp.delete(op);
|
|
114
|
+
else
|
|
115
|
+
fsIoInflightByOp.set(op, next);
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
function logFsIoEvent(entry) {
|
|
119
|
+
writeFsIoLog({
|
|
120
|
+
id: entry.id,
|
|
121
|
+
action: entry.action,
|
|
122
|
+
op: entry.op,
|
|
123
|
+
event: entry.event,
|
|
124
|
+
path: truncateLogValue(entry.path),
|
|
125
|
+
cwd: truncateLogValue(entry.cwd),
|
|
126
|
+
repoRoot: truncateLogValue(entry.repoRoot ?? undefined),
|
|
127
|
+
requestClosed: entry.requestClosed,
|
|
128
|
+
childPid: entry.childPid,
|
|
129
|
+
args: entry.args,
|
|
130
|
+
code: entry.code,
|
|
131
|
+
error: truncateLogValue(entry.error),
|
|
132
|
+
inflight: Object.fromEntries(fsIoInflightByOp.entries()),
|
|
133
|
+
...entry.extra,
|
|
134
|
+
});
|
|
135
|
+
if (entry.code || entry.error || entry.event.includes('abort') || entry.event.includes('timeout')) {
|
|
136
|
+
writeErrorLog({
|
|
137
|
+
source: 'fs-io',
|
|
138
|
+
id: entry.id,
|
|
139
|
+
action: entry.action,
|
|
140
|
+
op: entry.op,
|
|
141
|
+
event: entry.event,
|
|
142
|
+
path: truncateLogValue(entry.path),
|
|
143
|
+
cwd: truncateLogValue(entry.cwd),
|
|
144
|
+
repoRoot: truncateLogValue(entry.repoRoot ?? undefined),
|
|
145
|
+
requestClosed: entry.requestClosed,
|
|
146
|
+
childPid: entry.childPid,
|
|
147
|
+
args: entry.args,
|
|
148
|
+
code: entry.code,
|
|
149
|
+
error: truncateLogValue(entry.error),
|
|
150
|
+
...entry.extra,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function logFsIo(entry) {
|
|
155
|
+
const extra = entry.extra ?? {};
|
|
156
|
+
writeFsIoLog({
|
|
157
|
+
id: entry.id,
|
|
158
|
+
action: entry.action,
|
|
159
|
+
op: entry.op,
|
|
160
|
+
status: entry.status,
|
|
161
|
+
durationMs: Date.now() - entry.startedAt,
|
|
162
|
+
path: truncateLogValue(entry.path),
|
|
163
|
+
cwd: truncateLogValue(entry.cwd),
|
|
164
|
+
repoRoot: truncateLogValue(entry.repoRoot ?? undefined),
|
|
165
|
+
code: entry.code,
|
|
166
|
+
error: truncateLogValue(entry.error),
|
|
167
|
+
count: entry.count,
|
|
168
|
+
total: entry.total,
|
|
169
|
+
bytes: entry.bytes,
|
|
170
|
+
truncated: entry.truncated,
|
|
171
|
+
...extra,
|
|
172
|
+
});
|
|
173
|
+
if (entry.status === 'error' || entry.code || entry.error) {
|
|
174
|
+
writeErrorLog({
|
|
175
|
+
source: 'fs-io',
|
|
176
|
+
id: entry.id,
|
|
177
|
+
action: entry.action,
|
|
178
|
+
op: entry.op,
|
|
179
|
+
status: entry.status,
|
|
180
|
+
durationMs: Date.now() - entry.startedAt,
|
|
181
|
+
path: truncateLogValue(entry.path),
|
|
182
|
+
cwd: truncateLogValue(entry.cwd),
|
|
183
|
+
repoRoot: truncateLogValue(entry.repoRoot ?? undefined),
|
|
184
|
+
code: entry.code,
|
|
185
|
+
error: truncateLogValue(entry.error),
|
|
186
|
+
...extra,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function registerIoSlot(options) {
|
|
191
|
+
if (!options.slotId)
|
|
192
|
+
return;
|
|
193
|
+
const previous = activeIoSlots.get(options.slotId);
|
|
194
|
+
if (previous && previous.requestId !== options.requestId) {
|
|
195
|
+
previous.controller.abort(new SupersededRequestError(previous.op));
|
|
196
|
+
logFsIoEvent({
|
|
197
|
+
id: options.requestId,
|
|
198
|
+
action: options.action,
|
|
199
|
+
op: options.op,
|
|
200
|
+
event: 'slot-cancel-previous',
|
|
201
|
+
path: options.path,
|
|
202
|
+
cwd: options.cwd,
|
|
203
|
+
repoRoot: options.repoRoot,
|
|
204
|
+
extra: {
|
|
205
|
+
requestSlotId: options.slotId,
|
|
206
|
+
previousRequestId: previous.requestId,
|
|
207
|
+
previousOp: previous.op,
|
|
208
|
+
...options.extra,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
activeIoSlots.set(options.slotId, { controller: options.controller, requestId: options.requestId, op: options.op });
|
|
213
|
+
}
|
|
214
|
+
function releaseIoSlot(slotId, requestId) {
|
|
215
|
+
if (!slotId)
|
|
216
|
+
return;
|
|
217
|
+
if (activeIoSlots.get(slotId)?.requestId === requestId) {
|
|
218
|
+
activeIoSlots.delete(slotId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function throwIfAborted(signal, op) {
|
|
222
|
+
if (!signal.aborted)
|
|
223
|
+
return;
|
|
224
|
+
const reason = signal.reason;
|
|
225
|
+
if (reason instanceof Error)
|
|
226
|
+
throw reason;
|
|
227
|
+
throw new SupersededRequestError(op);
|
|
228
|
+
}
|
|
229
|
+
function getRequestAction(req, fallback) {
|
|
230
|
+
const raw = req.query.action;
|
|
231
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
232
|
+
if (!value || !/^[a-z][a-z0-9_:-]{0,80}$/.test(value))
|
|
233
|
+
return fallback;
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
function getGitDiffAlgorithm(value) {
|
|
237
|
+
return value === 'myers' || value === 'minimal' || value === 'patience' || value === 'histogram'
|
|
238
|
+
? value
|
|
239
|
+
: 'default';
|
|
240
|
+
}
|
|
241
|
+
function getGitDiffWhitespaceMode(value) {
|
|
242
|
+
return value === 'trim' || value === 'ignore' || value === 'ignore-blank-lines'
|
|
243
|
+
? value
|
|
244
|
+
: 'default';
|
|
245
|
+
}
|
|
246
|
+
function buildGitDiffOptionArgs(options) {
|
|
247
|
+
const args = [];
|
|
248
|
+
if (options.algorithm !== 'default')
|
|
249
|
+
args.push(`--diff-algorithm=${options.algorithm}`);
|
|
250
|
+
if (options.whitespace === 'trim')
|
|
251
|
+
args.push('--ignore-space-at-eol');
|
|
252
|
+
if (options.whitespace === 'ignore')
|
|
253
|
+
args.push('--ignore-all-space');
|
|
254
|
+
if (options.whitespace === 'ignore-blank-lines')
|
|
255
|
+
args.push('--ignore-blank-lines');
|
|
256
|
+
return args;
|
|
257
|
+
}
|
|
258
|
+
function compareDirents(a, b) {
|
|
259
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
260
|
+
return -1;
|
|
261
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
262
|
+
return 1;
|
|
263
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
264
|
+
}
|
|
265
|
+
const WATCH_IGNORED_NAMES = new Set([
|
|
266
|
+
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
|
|
267
|
+
]);
|
|
268
|
+
const NESTED_GIT_DISCOVERY_IGNORED_NAMES = new Set([
|
|
269
|
+
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
|
|
270
|
+
'.cache', '.parcel-cache', '.yarn', '.pnpm-store', 'vendor',
|
|
271
|
+
]);
|
|
272
|
+
const WATCH_BATCH_MS = 120;
|
|
273
|
+
const WATCH_EVENT_STORM_LIMIT = 1200;
|
|
274
|
+
const IMAGE_MIME_BY_EXT = {
|
|
275
|
+
'.png': 'image/png',
|
|
276
|
+
'.jpg': 'image/jpeg',
|
|
277
|
+
'.jpeg': 'image/jpeg',
|
|
278
|
+
'.gif': 'image/gif',
|
|
279
|
+
'.webp': 'image/webp',
|
|
280
|
+
'.avif': 'image/avif',
|
|
281
|
+
'.bmp': 'image/bmp',
|
|
282
|
+
'.ico': 'image/x-icon',
|
|
283
|
+
'.svg': 'image/svg+xml',
|
|
284
|
+
};
|
|
285
|
+
function getImageMimeType(filePath) {
|
|
286
|
+
return IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? null;
|
|
287
|
+
}
|
|
288
|
+
function toContentDispositionFilename(name) {
|
|
289
|
+
return name.replace(/["\\\r\n]/g, '_').replace(/[^\x20-\x7E]/g, '_');
|
|
290
|
+
}
|
|
291
|
+
function toRfc5987ValueChars(value) {
|
|
292
|
+
return encodeURIComponent(value).replace(/['()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
293
|
+
}
|
|
294
|
+
export function buildContentDisposition(disposition, filename) {
|
|
295
|
+
const fallback = toContentDispositionFilename(filename) || 'download';
|
|
296
|
+
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${toRfc5987ValueChars(filename)}`;
|
|
297
|
+
}
|
|
298
|
+
function isPathInside(parent, child) {
|
|
299
|
+
const relative = path.relative(parent, child);
|
|
300
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
301
|
+
}
|
|
302
|
+
async function isWorkspaceGitRepositoryRoot(workspaceRoot, repoRoot, signal) {
|
|
303
|
+
if (isPathInside(workspaceRoot, repoRoot))
|
|
304
|
+
return true;
|
|
305
|
+
const { repositories } = await getCachedNestedGitRoots(workspaceRoot, { refresh: false, signal });
|
|
306
|
+
return repositories.some((repo) => repo.root === repoRoot && isPathInside(workspaceRoot, repo.displayRoot));
|
|
307
|
+
}
|
|
308
|
+
async function toGitPathspec(gitRoot, requestedPath) {
|
|
309
|
+
const absoluteCandidate = path.isAbsolute(requestedPath)
|
|
310
|
+
? path.resolve(requestedPath)
|
|
311
|
+
: path.resolve(gitRoot, requestedPath);
|
|
312
|
+
let candidate = absoluteCandidate;
|
|
313
|
+
try {
|
|
314
|
+
candidate = await pathValidator.validatePathAsync(absoluteCandidate);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (!(error instanceof Error) || !error.message.includes('does not exist')) {
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!isPathInside(gitRoot, candidate)) {
|
|
322
|
+
throw new Error('Path is outside git repository');
|
|
323
|
+
}
|
|
324
|
+
return path.relative(gitRoot, candidate).split(path.sep).join('/');
|
|
325
|
+
}
|
|
326
|
+
function getDiffByteLength(diff) {
|
|
327
|
+
return Buffer.byteLength(diff, 'utf8');
|
|
328
|
+
}
|
|
329
|
+
function truncateDiffIfNeeded(payload) {
|
|
330
|
+
const size = getDiffByteLength(payload.diff);
|
|
331
|
+
if (size <= MAX_DIFF_BYTES) {
|
|
332
|
+
return { ...payload, size };
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
...payload,
|
|
336
|
+
diff: '',
|
|
337
|
+
size,
|
|
338
|
+
maxBytes: MAX_DIFF_BYTES,
|
|
339
|
+
truncated: true,
|
|
340
|
+
tooLarge: true,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
async function getRelativeFileSize(gitRoot, filePath) {
|
|
344
|
+
try {
|
|
345
|
+
const absolutePath = path.resolve(gitRoot, filePath);
|
|
346
|
+
if (!isPathInside(gitRoot, absolutePath))
|
|
347
|
+
return null;
|
|
348
|
+
const stat = await fs.promises.stat(absolutePath);
|
|
349
|
+
return stat.isFile() ? stat.size : null;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function makeSkippedUntracked(pathspec, size) {
|
|
356
|
+
return {
|
|
357
|
+
path: pathspec,
|
|
358
|
+
reason: 'untracked-file-too-large',
|
|
359
|
+
size: size ?? undefined,
|
|
360
|
+
maxBytes: MAX_UNTRACKED_DIFF_FILE_BYTES,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function appendUntrackedDiffs(gitRoot, baseDiff, signal, options) {
|
|
364
|
+
const output = await execGit(['ls-files', '--others', '--exclude-standard', '-z'], gitRoot, signal).catch(emptyOnNonAbortGitError);
|
|
365
|
+
const files = output.split('\0').filter(Boolean);
|
|
366
|
+
const skippedFiles = [];
|
|
367
|
+
let diff = baseDiff;
|
|
368
|
+
let truncated = false;
|
|
369
|
+
for (const filePath of files) {
|
|
370
|
+
const size = await getRelativeFileSize(gitRoot, filePath);
|
|
371
|
+
if (size !== null && size > options.perFileMaxBytes) {
|
|
372
|
+
skippedFiles.push(makeSkippedUntracked(filePath, size));
|
|
373
|
+
truncated = true;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const partial = await execGitLimited(['diff', '--no-index', '--', '/dev/null', filePath], gitRoot, options.perFileMaxBytes, true, signal)
|
|
377
|
+
.then((result) => {
|
|
378
|
+
if (result.truncated)
|
|
379
|
+
truncated = true;
|
|
380
|
+
return result.stdout;
|
|
381
|
+
})
|
|
382
|
+
.catch(emptyOnNonAbortGitError);
|
|
383
|
+
if (!partial)
|
|
384
|
+
continue;
|
|
385
|
+
const nextDiff = diff ? `${diff}\n${partial}` : partial;
|
|
386
|
+
if (getDiffByteLength(nextDiff) > options.maxBytes) {
|
|
387
|
+
skippedFiles.push({ path: filePath, reason: 'diff-byte-limit-exceeded', size: getDiffByteLength(partial), maxBytes: options.maxBytes });
|
|
388
|
+
truncated = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
diff = nextDiff;
|
|
392
|
+
}
|
|
393
|
+
return { diff, files, skippedFiles, truncated };
|
|
394
|
+
}
|
|
395
|
+
function normalizeNameStatus(status) {
|
|
396
|
+
if (status.startsWith('R'))
|
|
397
|
+
return 'renamed';
|
|
398
|
+
if (status.startsWith('C'))
|
|
399
|
+
return 'copied';
|
|
400
|
+
if (status.startsWith('A'))
|
|
401
|
+
return 'added';
|
|
402
|
+
if (status.startsWith('D'))
|
|
403
|
+
return 'deleted';
|
|
404
|
+
if (status.startsWith('U'))
|
|
405
|
+
return 'conflicted';
|
|
406
|
+
if (status.includes('U'))
|
|
407
|
+
return 'conflicted';
|
|
408
|
+
if (status.startsWith('?'))
|
|
409
|
+
return 'untracked';
|
|
410
|
+
if (status.startsWith('M') || status.startsWith('T'))
|
|
411
|
+
return 'modified';
|
|
412
|
+
return 'modified';
|
|
413
|
+
}
|
|
414
|
+
function combineChangeStatus(file) {
|
|
415
|
+
const statuses = [file.indexStatus, file.worktreeStatus].filter(Boolean);
|
|
416
|
+
if (statuses.some((status) => normalizeNameStatus(status) === 'conflicted'))
|
|
417
|
+
return 'conflicted';
|
|
418
|
+
if (file.untracked)
|
|
419
|
+
return 'untracked';
|
|
420
|
+
if (statuses.some((status) => status.startsWith('R')))
|
|
421
|
+
return 'renamed';
|
|
422
|
+
if (statuses.some((status) => status.startsWith('C')))
|
|
423
|
+
return 'copied';
|
|
424
|
+
if (statuses.some((status) => status.startsWith('D')))
|
|
425
|
+
return 'deleted';
|
|
426
|
+
if (statuses.some((status) => status.startsWith('A')))
|
|
427
|
+
return 'added';
|
|
428
|
+
if (statuses.some((status) => status.startsWith('M') || status.startsWith('T')))
|
|
429
|
+
return 'modified';
|
|
430
|
+
return file.status === 'unknown' ? 'modified' : file.status;
|
|
431
|
+
}
|
|
432
|
+
function emptyChangedFile(gitRoot, filePath) {
|
|
433
|
+
return {
|
|
434
|
+
path: filePath,
|
|
435
|
+
absolutePath: path.join(gitRoot, filePath),
|
|
436
|
+
repoRoot: gitRoot,
|
|
437
|
+
status: 'unknown',
|
|
438
|
+
staged: false,
|
|
439
|
+
unstaged: false,
|
|
440
|
+
untracked: false,
|
|
441
|
+
tracked: true,
|
|
442
|
+
canStage: false,
|
|
443
|
+
canUnstage: false,
|
|
444
|
+
canStash: false,
|
|
445
|
+
canRestoreWorktree: false,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function mergeNameStatus(files, gitRoot, output, source) {
|
|
449
|
+
const tokens = output.split('\0').filter(Boolean);
|
|
450
|
+
for (let i = 0; i < tokens.length;) {
|
|
451
|
+
const rawStatus = tokens[i++];
|
|
452
|
+
if (!rawStatus)
|
|
453
|
+
break;
|
|
454
|
+
let oldPath;
|
|
455
|
+
let filePath;
|
|
456
|
+
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
|
|
457
|
+
oldPath = tokens[i++];
|
|
458
|
+
filePath = tokens[i++];
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
filePath = tokens[i++];
|
|
462
|
+
}
|
|
463
|
+
if (!filePath)
|
|
464
|
+
continue;
|
|
465
|
+
const current = files.get(filePath) ?? emptyChangedFile(gitRoot, filePath);
|
|
466
|
+
current.status = normalizeNameStatus(rawStatus);
|
|
467
|
+
if (oldPath)
|
|
468
|
+
current.oldPath = oldPath;
|
|
469
|
+
if (source === 'staged') {
|
|
470
|
+
current.staged = true;
|
|
471
|
+
current.indexStatus = rawStatus;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
current.unstaged = true;
|
|
475
|
+
current.worktreeStatus = rawStatus;
|
|
476
|
+
}
|
|
477
|
+
current.tracked = true;
|
|
478
|
+
files.set(filePath, current);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function finalizeChangedFiles(files) {
|
|
482
|
+
return Array.from(files.values())
|
|
483
|
+
.map((file) => ({
|
|
484
|
+
...file,
|
|
485
|
+
status: combineChangeStatus(file),
|
|
486
|
+
canStage: file.unstaged || file.untracked,
|
|
487
|
+
canUnstage: file.staged,
|
|
488
|
+
canStash: file.unstaged || file.untracked,
|
|
489
|
+
canRestoreWorktree: file.tracked && file.unstaged && !file.untracked,
|
|
490
|
+
}))
|
|
491
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
492
|
+
}
|
|
493
|
+
function countStagedFiles(files) {
|
|
494
|
+
return files.reduce((count, file) => count + (file.staged ? 1 : 0), 0);
|
|
495
|
+
}
|
|
496
|
+
async function getChangedFiles(gitRoot, signal, options = {}) {
|
|
497
|
+
const includeUntracked = options.includeUntracked !== false;
|
|
498
|
+
const [stagedOutput, unstagedOutput, untrackedResult] = await Promise.all([
|
|
499
|
+
execGit(['diff', '--cached', '--name-status', '-M', '-z'], gitRoot, signal, options.gitTimeoutMs).catch(emptyOnNonAbortGitError),
|
|
500
|
+
execGit(['diff', '--name-status', '-M', '-z'], gitRoot, signal, options.gitTimeoutMs).catch(emptyOnNonAbortGitError),
|
|
501
|
+
includeUntracked
|
|
502
|
+
? execGit(['ls-files', '--others', '--exclude-standard', '-z'], gitRoot, signal, options.untrackedTimeoutMs ?? GIT_UNTRACKED_TIMEOUT_MS)
|
|
503
|
+
.then((output) => ({ output, deferred: false }))
|
|
504
|
+
.catch((error) => {
|
|
505
|
+
if (error instanceof GitCommandAbortError || error instanceof OperationTimeoutError || error instanceof SupersededRequestError) {
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
return { output: '', deferred: true };
|
|
509
|
+
})
|
|
510
|
+
: Promise.resolve({ output: '', deferred: false }),
|
|
511
|
+
]);
|
|
512
|
+
const files = new Map();
|
|
513
|
+
mergeNameStatus(files, gitRoot, stagedOutput, 'staged');
|
|
514
|
+
mergeNameStatus(files, gitRoot, unstagedOutput, 'unstaged');
|
|
515
|
+
for (const p of untrackedResult.output.split('\0').filter(Boolean)) {
|
|
516
|
+
const current = files.get(p) ?? emptyChangedFile(gitRoot, p);
|
|
517
|
+
current.status = 'untracked';
|
|
518
|
+
current.untracked = true;
|
|
519
|
+
current.unstaged = true;
|
|
520
|
+
current.tracked = false;
|
|
521
|
+
files.set(p, current);
|
|
522
|
+
}
|
|
523
|
+
return { files: finalizeChangedFiles(files), untrackedDeferred: includeUntracked ? untrackedResult.deferred : true };
|
|
524
|
+
}
|
|
525
|
+
function toContextFiles(files) {
|
|
526
|
+
return files
|
|
527
|
+
.slice(0, MAX_GIT_CONTEXT_CHANGED_FILES)
|
|
528
|
+
.map((file) => ({ path: file.path, absolutePath: file.absolutePath, status: file.status }));
|
|
529
|
+
}
|
|
530
|
+
function uniqueSortedLines(output) {
|
|
531
|
+
return Array.from(new Set(output
|
|
532
|
+
.split('\n')
|
|
533
|
+
.map((line) => line.trim())
|
|
534
|
+
.filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
|
535
|
+
}
|
|
536
|
+
function splitUpstream(upstream, remotes) {
|
|
537
|
+
if (!upstream)
|
|
538
|
+
return { remote: null, branch: null };
|
|
539
|
+
const remote = remotes
|
|
540
|
+
.filter((candidate) => upstream === candidate || upstream.startsWith(`${candidate}/`))
|
|
541
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
542
|
+
if (!remote || upstream === remote)
|
|
543
|
+
return { remote: null, branch: null };
|
|
544
|
+
return { remote, branch: upstream.slice(remote.length + 1) || null };
|
|
545
|
+
}
|
|
546
|
+
function parseAheadBehind(output, hasUpstream) {
|
|
547
|
+
if (!hasUpstream)
|
|
548
|
+
return { ahead: null, behind: null };
|
|
549
|
+
const [behindRaw, aheadRaw] = output.trim().split(/\s+/);
|
|
550
|
+
const behind = Number.parseInt(behindRaw ?? '', 10);
|
|
551
|
+
const ahead = Number.parseInt(aheadRaw ?? '', 10);
|
|
552
|
+
return {
|
|
553
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
554
|
+
behind: Number.isFinite(behind) ? behind : 0,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function emptyOnNonAbortGitError(error) {
|
|
558
|
+
if (error instanceof GitCommandAbortError || error instanceof OperationTimeoutError || error instanceof SupersededRequestError) {
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
return '';
|
|
562
|
+
}
|
|
563
|
+
function normalizeGitTimeoutError(error, message, code) {
|
|
564
|
+
if (error instanceof Error && error.message === 'git command timed out') {
|
|
565
|
+
return new OperationTimeoutError(message, code);
|
|
566
|
+
}
|
|
567
|
+
return error;
|
|
568
|
+
}
|
|
569
|
+
function buildUntrackedFiles(gitRoot, output, nestedDisplayRoots = new Set()) {
|
|
570
|
+
return finalizeChangedFiles(new Map(output.split('\0').filter(Boolean).map((p) => {
|
|
571
|
+
const file = emptyChangedFile(gitRoot, p);
|
|
572
|
+
file.status = 'untracked';
|
|
573
|
+
file.untracked = true;
|
|
574
|
+
file.unstaged = true;
|
|
575
|
+
file.tracked = false;
|
|
576
|
+
return [p, file];
|
|
577
|
+
}).filter(([, file]) => !isNestedRepoPlaceholderFile(file, nestedDisplayRoots))));
|
|
578
|
+
}
|
|
579
|
+
function startUntrackedJob(gitRoot, nestedDisplayRoots = new Set()) {
|
|
580
|
+
const cacheKey = nestedDisplayRoots.size > 0
|
|
581
|
+
? `${gitRoot}\0${Array.from(nestedDisplayRoots).sort().join('\0')}`
|
|
582
|
+
: gitRoot;
|
|
583
|
+
const current = untrackedJobs.get(cacheKey);
|
|
584
|
+
if (current?.status === 'running') {
|
|
585
|
+
return { status: 'running', files: [], startedAt: current.startedAt };
|
|
586
|
+
}
|
|
587
|
+
if (current?.status === 'done' && current.finishedAt && Date.now() - current.finishedAt < GIT_BUNDLE_CACHE_TTL_MS) {
|
|
588
|
+
return { status: 'done', files: current.files ?? [], startedAt: current.startedAt, finishedAt: current.finishedAt };
|
|
589
|
+
}
|
|
590
|
+
if (current?.status === 'error' && current.finishedAt && Date.now() - current.finishedAt < GIT_BUNDLE_CACHE_TTL_MS) {
|
|
591
|
+
return {
|
|
592
|
+
status: 'error',
|
|
593
|
+
files: [],
|
|
594
|
+
error: current.error,
|
|
595
|
+
code: current.code,
|
|
596
|
+
startedAt: current.startedAt,
|
|
597
|
+
finishedAt: current.finishedAt,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const startedAt = Date.now();
|
|
601
|
+
const job = {
|
|
602
|
+
status: 'running',
|
|
603
|
+
startedAt,
|
|
604
|
+
};
|
|
605
|
+
const promise = execGit(['ls-files', '--others', '--exclude-standard', '-z'], gitRoot, undefined, null)
|
|
606
|
+
.then((output) => {
|
|
607
|
+
job.status = 'done';
|
|
608
|
+
job.files = buildUntrackedFiles(gitRoot, output, nestedDisplayRoots);
|
|
609
|
+
updateGitBundleCachesWithUntracked(gitRoot, job.files);
|
|
610
|
+
job.finishedAt = Date.now();
|
|
611
|
+
})
|
|
612
|
+
.catch((error) => {
|
|
613
|
+
const payload = getErrorPayload(normalizeGitTimeoutError(error, 'Untracked file scan took too long. The scan is still separate from the main Git refresh; try again later.', 'GIT_UNTRACKED_TIMEOUT'));
|
|
614
|
+
job.status = 'error';
|
|
615
|
+
job.error = payload.error;
|
|
616
|
+
job.code = payload.code;
|
|
617
|
+
job.files = [];
|
|
618
|
+
job.finishedAt = Date.now();
|
|
619
|
+
});
|
|
620
|
+
job.promise = promise;
|
|
621
|
+
untrackedJobs.set(cacheKey, job);
|
|
622
|
+
void promise;
|
|
623
|
+
return { status: 'running', files: [], startedAt };
|
|
624
|
+
}
|
|
625
|
+
async function getGitPushTargets(gitRoot, signal) {
|
|
626
|
+
const [remotesOutput, branchesOutput, remoteBranchesOutput, upstreamOutput, aheadBehindOutput] = await Promise.all([
|
|
627
|
+
execGit(['remote'], gitRoot, signal).catch(emptyOnNonAbortGitError),
|
|
628
|
+
execGit(['for-each-ref', '--format=%(refname:short)', 'refs/heads'], gitRoot, signal).catch(emptyOnNonAbortGitError),
|
|
629
|
+
execGit(['for-each-ref', '--format=%(refname:short)', 'refs/remotes'], gitRoot, signal).catch(emptyOnNonAbortGitError),
|
|
630
|
+
execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], gitRoot, signal).catch(emptyOnNonAbortGitError),
|
|
631
|
+
execGit(['rev-list', '--left-right', '--count', '@{u}...HEAD'], gitRoot, signal).catch(emptyOnNonAbortGitError),
|
|
632
|
+
]);
|
|
633
|
+
const remotes = uniqueSortedLines(remotesOutput);
|
|
634
|
+
const branches = uniqueSortedLines(branchesOutput);
|
|
635
|
+
const remoteBranches = uniqueSortedLines(remoteBranchesOutput)
|
|
636
|
+
.filter((branch) => !branch.endsWith('/HEAD'));
|
|
637
|
+
const upstream = upstreamOutput.trim() || null;
|
|
638
|
+
const { remote: upstreamRemote, branch: upstreamBranch } = splitUpstream(upstream, remotes);
|
|
639
|
+
const { ahead, behind } = parseAheadBehind(aheadBehindOutput, Boolean(upstream));
|
|
640
|
+
return { remotes, branches, remoteBranches, upstream, upstreamRemote, upstreamBranch, ahead, behind };
|
|
641
|
+
}
|
|
642
|
+
function getRepoRelativeRoot(workspaceRoot, repoRoot, displayRoot = repoRoot) {
|
|
643
|
+
const relative = path.relative(workspaceRoot, displayRoot).split(path.sep).join('/');
|
|
644
|
+
return relative || '.';
|
|
645
|
+
}
|
|
646
|
+
function getRepoDepth(workspaceRoot, repoRoot, displayRoot = repoRoot) {
|
|
647
|
+
const relative = path.relative(workspaceRoot, displayRoot);
|
|
648
|
+
if (!relative)
|
|
649
|
+
return 0;
|
|
650
|
+
return relative.split(path.sep).filter(Boolean).length;
|
|
651
|
+
}
|
|
652
|
+
function annotateRepoFiles(files, workspaceRoot, repoRoot, displayRoot = repoRoot) {
|
|
653
|
+
const relativeRoot = getRepoRelativeRoot(workspaceRoot, repoRoot, displayRoot);
|
|
654
|
+
const repoName = relativeRoot === '.' ? path.basename(displayRoot) || displayRoot : relativeRoot;
|
|
655
|
+
return files.map((file) => ({
|
|
656
|
+
...file,
|
|
657
|
+
repoRoot,
|
|
658
|
+
repoRelativeRoot: relativeRoot,
|
|
659
|
+
repoName,
|
|
660
|
+
absolutePath: path.join(repoRoot, file.path),
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
function isNestedRepoPlaceholderFile(file, nestedDisplayRoots) {
|
|
664
|
+
if (!file.untracked || file.tracked)
|
|
665
|
+
return false;
|
|
666
|
+
const normalizedPath = file.path.replace(/\/+$/, '');
|
|
667
|
+
return nestedDisplayRoots.has(normalizedPath);
|
|
668
|
+
}
|
|
669
|
+
function buildNestedRepoDisplayRootSet(workspaceRoot, repositories) {
|
|
670
|
+
return new Set(repositories.map((repo) => (path.relative(workspaceRoot, repo.displayRoot).split(path.sep).join('/').replace(/\/+$/, ''))));
|
|
671
|
+
}
|
|
672
|
+
async function buildGitBundle(resolvedCwd, gitRoot, signal, options = {}) {
|
|
673
|
+
const [branchOutput, changedResult] = await Promise.all([
|
|
674
|
+
execGit(['branch', '--show-current'], gitRoot, signal, options.gitTimeoutMs).catch(emptyOnNonAbortGitError),
|
|
675
|
+
getChangedFiles(gitRoot, signal, { includeUntracked: false, gitTimeoutMs: options.gitTimeoutMs }),
|
|
676
|
+
]);
|
|
677
|
+
const files = changedResult.files;
|
|
678
|
+
const annotatedFiles = annotateRepoFiles(files, gitRoot, gitRoot);
|
|
679
|
+
const changedFiles = toContextFiles(annotatedFiles);
|
|
680
|
+
return {
|
|
681
|
+
available: true,
|
|
682
|
+
files: annotatedFiles,
|
|
683
|
+
context: {
|
|
684
|
+
available: true,
|
|
685
|
+
cwd: resolvedCwd,
|
|
686
|
+
root: gitRoot,
|
|
687
|
+
branch: branchOutput.trim() || null,
|
|
688
|
+
remotes: [],
|
|
689
|
+
branches: [],
|
|
690
|
+
upstream: null,
|
|
691
|
+
upstreamRemote: null,
|
|
692
|
+
upstreamBranch: null,
|
|
693
|
+
ahead: null,
|
|
694
|
+
behind: null,
|
|
695
|
+
status: '',
|
|
696
|
+
changedFiles,
|
|
697
|
+
truncated: changedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
698
|
+
},
|
|
699
|
+
untrackedDeferred: changedResult.untrackedDeferred,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
async function buildGitRepositoryBundle(workspaceRoot, resolvedCwd, repoRoot, displayRoot = repoRoot, signal, options = {}) {
|
|
703
|
+
try {
|
|
704
|
+
if (signal)
|
|
705
|
+
throwIfAborted(signal, 'git.bundle');
|
|
706
|
+
const bundle = await buildGitBundle(repoRoot === workspaceRoot ? resolvedCwd : repoRoot, repoRoot, signal, options);
|
|
707
|
+
if (signal)
|
|
708
|
+
throwIfAborted(signal, 'git.bundle');
|
|
709
|
+
const relativeRoot = getRepoRelativeRoot(workspaceRoot, repoRoot, displayRoot);
|
|
710
|
+
const files = annotateRepoFiles(bundle.files, workspaceRoot, repoRoot, displayRoot);
|
|
711
|
+
const changedFiles = toContextFiles(files);
|
|
712
|
+
return {
|
|
713
|
+
id: repoRoot,
|
|
714
|
+
root: repoRoot,
|
|
715
|
+
displayRoot,
|
|
716
|
+
relativeRoot,
|
|
717
|
+
name: relativeRoot === '.' ? path.basename(displayRoot) || displayRoot : relativeRoot,
|
|
718
|
+
depth: getRepoDepth(workspaceRoot, repoRoot, displayRoot),
|
|
719
|
+
nested: repoRoot !== workspaceRoot,
|
|
720
|
+
available: true,
|
|
721
|
+
files,
|
|
722
|
+
context: bundle.context ? {
|
|
723
|
+
...bundle.context,
|
|
724
|
+
cwd: repoRoot === workspaceRoot ? resolvedCwd : repoRoot,
|
|
725
|
+
root: repoRoot,
|
|
726
|
+
changedFiles,
|
|
727
|
+
truncated: changedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
728
|
+
} : null,
|
|
729
|
+
untrackedDeferred: bundle.untrackedDeferred,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
if (error instanceof GitCommandAbortError || error instanceof OperationTimeoutError || error instanceof SupersededRequestError) {
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
736
|
+
const relativeRoot = getRepoRelativeRoot(workspaceRoot, repoRoot, displayRoot);
|
|
737
|
+
return {
|
|
738
|
+
id: repoRoot,
|
|
739
|
+
root: repoRoot,
|
|
740
|
+
displayRoot,
|
|
741
|
+
relativeRoot,
|
|
742
|
+
name: relativeRoot === '.' ? path.basename(displayRoot) || displayRoot : relativeRoot,
|
|
743
|
+
depth: getRepoDepth(workspaceRoot, repoRoot, displayRoot),
|
|
744
|
+
nested: repoRoot !== workspaceRoot,
|
|
745
|
+
available: false,
|
|
746
|
+
files: [],
|
|
747
|
+
context: { available: false, cwd: repoRoot, root: repoRoot, error: error instanceof Error ? error.message : 'Unknown error' },
|
|
748
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function buildGitRepositoryFilters(workspaceRoot, repositories) {
|
|
753
|
+
return repositories
|
|
754
|
+
.filter((repo) => repo.files.length > 0)
|
|
755
|
+
.map((repo) => ({
|
|
756
|
+
root: repo.root,
|
|
757
|
+
label: repo.relativeRoot === '.' ? path.basename(workspaceRoot) || workspaceRoot : (repo.relativeRoot || repo.name || path.basename(repo.root) || repo.root),
|
|
758
|
+
branch: repo.context?.branch ?? null,
|
|
759
|
+
count: repo.files.length,
|
|
760
|
+
staged: countStagedFiles(repo.files),
|
|
761
|
+
}))
|
|
762
|
+
.sort((a, b) => {
|
|
763
|
+
const rootLabel = path.basename(workspaceRoot) || workspaceRoot;
|
|
764
|
+
if (a.label === rootLabel)
|
|
765
|
+
return -1;
|
|
766
|
+
if (b.label === rootLabel)
|
|
767
|
+
return 1;
|
|
768
|
+
return a.label.localeCompare(b.label);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function mergeChangedFilesByPath(current, incoming) {
|
|
772
|
+
const files = new Map();
|
|
773
|
+
for (const file of current)
|
|
774
|
+
files.set(file.path, file);
|
|
775
|
+
for (const file of incoming)
|
|
776
|
+
files.set(file.path, file);
|
|
777
|
+
return Array.from(files.values()).sort((a, b) => a.path.localeCompare(b.path));
|
|
778
|
+
}
|
|
779
|
+
function updateGitBundleCachesWithUntracked(repoRoot, files) {
|
|
780
|
+
if (files.length === 0)
|
|
781
|
+
return;
|
|
782
|
+
const now = Date.now();
|
|
783
|
+
for (const [cacheKey, cached] of gitBundleCache.entries()) {
|
|
784
|
+
const bundle = cached.bundle;
|
|
785
|
+
const repositories = bundle.repositories;
|
|
786
|
+
if (repositories?.length) {
|
|
787
|
+
let changed = false;
|
|
788
|
+
const nextRepositories = repositories.map((repo) => {
|
|
789
|
+
if (repo.root !== repoRoot)
|
|
790
|
+
return repo;
|
|
791
|
+
const annotatedFiles = annotateRepoFiles(files, repo.root, repo.root, repo.displayRoot ?? repo.root);
|
|
792
|
+
const mergedFiles = mergeChangedFilesByPath(repo.files, annotatedFiles);
|
|
793
|
+
changed = true;
|
|
794
|
+
return {
|
|
795
|
+
...repo,
|
|
796
|
+
files: mergedFiles,
|
|
797
|
+
context: repo.context ? {
|
|
798
|
+
...repo.context,
|
|
799
|
+
changedFiles: toContextFiles(mergedFiles),
|
|
800
|
+
truncated: mergedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
801
|
+
} : repo.context,
|
|
802
|
+
untrackedDeferred: false,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
if (!changed)
|
|
806
|
+
continue;
|
|
807
|
+
const primary = nextRepositories[0];
|
|
808
|
+
const nextBundle = {
|
|
809
|
+
...bundle,
|
|
810
|
+
files: nextRepositories.flatMap((repo) => repo.files),
|
|
811
|
+
context: primary?.context ?? bundle.context,
|
|
812
|
+
repositories: nextRepositories,
|
|
813
|
+
repoFilters: buildGitRepositoryFilters(primary?.root ?? repoRoot, nextRepositories),
|
|
814
|
+
untrackedDeferred: nextRepositories.some((repo) => repo.untrackedDeferred),
|
|
815
|
+
};
|
|
816
|
+
gitBundleCache.set(cacheKey, { bundle: nextBundle, expiresAt: now + GIT_BUNDLE_CACHE_TTL_MS });
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (bundle.context?.root !== repoRoot)
|
|
820
|
+
continue;
|
|
821
|
+
const mergedFiles = mergeChangedFilesByPath(bundle.files, files);
|
|
822
|
+
const nextContext = bundle.context ? {
|
|
823
|
+
...bundle.context,
|
|
824
|
+
changedFiles: toContextFiles(mergedFiles),
|
|
825
|
+
truncated: mergedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
826
|
+
} : bundle.context;
|
|
827
|
+
const nextBundle = {
|
|
828
|
+
...bundle,
|
|
829
|
+
files: mergedFiles,
|
|
830
|
+
context: nextContext,
|
|
831
|
+
untrackedDeferred: false,
|
|
832
|
+
};
|
|
833
|
+
gitBundleCache.set(cacheKey, { bundle: nextBundle, expiresAt: now + GIT_BUNDLE_CACHE_TTL_MS });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async function buildWorkspaceGitBundle(resolvedCwd, gitRoot, includeNested, signal, options = {}) {
|
|
837
|
+
if (!includeNested) {
|
|
838
|
+
if (signal)
|
|
839
|
+
throwIfAborted(signal, 'git.bundle');
|
|
840
|
+
const bundle = await buildGitBundle(resolvedCwd, gitRoot, signal, options);
|
|
841
|
+
const repository = {
|
|
842
|
+
id: gitRoot,
|
|
843
|
+
root: gitRoot,
|
|
844
|
+
displayRoot: gitRoot,
|
|
845
|
+
relativeRoot: '.',
|
|
846
|
+
name: path.basename(gitRoot) || gitRoot,
|
|
847
|
+
depth: 0,
|
|
848
|
+
nested: false,
|
|
849
|
+
available: bundle.available,
|
|
850
|
+
files: bundle.files,
|
|
851
|
+
context: bundle.context,
|
|
852
|
+
error: bundle.error,
|
|
853
|
+
};
|
|
854
|
+
return {
|
|
855
|
+
...bundle,
|
|
856
|
+
repositories: [repository],
|
|
857
|
+
repoFilters: buildGitRepositoryFilters(gitRoot, [repository]),
|
|
858
|
+
untrackedDeferred: bundle.untrackedDeferred,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (signal)
|
|
862
|
+
throwIfAborted(signal, 'git.bundle');
|
|
863
|
+
const { repositories: nestedRepositories, truncated } = await getCachedNestedGitRoots(gitRoot, { refresh: true, signal });
|
|
864
|
+
if (signal)
|
|
865
|
+
throwIfAborted(signal, 'git.bundle');
|
|
866
|
+
const nestedDisplayRoots = buildNestedRepoDisplayRootSet(gitRoot, nestedRepositories);
|
|
867
|
+
const repositories = await Promise.all([
|
|
868
|
+
buildGitRepositoryBundle(gitRoot, resolvedCwd, gitRoot, gitRoot, signal, options),
|
|
869
|
+
...nestedRepositories.map((repo) => buildGitRepositoryBundle(gitRoot, resolvedCwd, repo.root, repo.displayRoot, signal, options)),
|
|
870
|
+
]);
|
|
871
|
+
if (signal)
|
|
872
|
+
throwIfAborted(signal, 'git.bundle');
|
|
873
|
+
const primary = repositories[0];
|
|
874
|
+
if (primary) {
|
|
875
|
+
primary.files = primary.files.filter((file) => !isNestedRepoPlaceholderFile(file, nestedDisplayRoots));
|
|
876
|
+
if (primary.context) {
|
|
877
|
+
const changedFiles = toContextFiles(primary.files);
|
|
878
|
+
primary.context = {
|
|
879
|
+
...primary.context,
|
|
880
|
+
changedFiles,
|
|
881
|
+
truncated: changedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
available: true,
|
|
887
|
+
files: repositories.flatMap((repo) => repo.files),
|
|
888
|
+
context: primary.context,
|
|
889
|
+
repositories,
|
|
890
|
+
repoFilters: buildGitRepositoryFilters(gitRoot, repositories),
|
|
891
|
+
truncatedRepositories: truncated,
|
|
892
|
+
untrackedDeferred: repositories.some((repo) => repo.untrackedDeferred),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
async function getCachedGitBundle(resolvedCwd, gitRoot, includeNested, refresh, allowStale = false, signal) {
|
|
896
|
+
const cacheKey = getGitBundleCacheKey(gitRoot, includeNested);
|
|
897
|
+
const now = Date.now();
|
|
898
|
+
if (signal)
|
|
899
|
+
throwIfAborted(signal, 'git.bundle');
|
|
900
|
+
const cached = gitBundleCache.get(cacheKey);
|
|
901
|
+
if (!refresh && cached && (cached.expiresAt > now || allowStale)) {
|
|
902
|
+
const cacheAgeMs = Math.max(0, now - (cached.expiresAt - GIT_BUNDLE_CACHE_TTL_MS));
|
|
903
|
+
return {
|
|
904
|
+
...cached.bundle,
|
|
905
|
+
cached: true,
|
|
906
|
+
stale: cached.expiresAt <= now,
|
|
907
|
+
cacheAgeMs,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const canReusePending = !refresh && !signal;
|
|
911
|
+
const pending = canReusePending ? gitBundleBuildPromises.get(cacheKey) : null;
|
|
912
|
+
if (pending) {
|
|
913
|
+
const bundle = await pending;
|
|
914
|
+
return { ...bundle, cached: true, stale: false, cacheAgeMs: 0 };
|
|
915
|
+
}
|
|
916
|
+
const promise = buildWorkspaceGitBundle(resolvedCwd, gitRoot, includeNested, signal)
|
|
917
|
+
.then((bundle) => {
|
|
918
|
+
gitBundleCache.set(cacheKey, { bundle, expiresAt: Date.now() + GIT_BUNDLE_CACHE_TTL_MS });
|
|
919
|
+
return bundle;
|
|
920
|
+
})
|
|
921
|
+
.finally(() => {
|
|
922
|
+
if (gitBundleBuildPromises.get(cacheKey) === promise)
|
|
923
|
+
gitBundleBuildPromises.delete(cacheKey);
|
|
924
|
+
});
|
|
925
|
+
if (canReusePending)
|
|
926
|
+
gitBundleBuildPromises.set(cacheKey, promise);
|
|
927
|
+
return promise;
|
|
928
|
+
}
|
|
929
|
+
async function refreshGitBundleCacheDetached(resolvedCwd, gitRoot, includeNested, options = {}) {
|
|
930
|
+
const cacheKey = getGitBundleCacheKey(gitRoot, includeNested);
|
|
931
|
+
const pending = gitBundleBuildPromises.get(cacheKey);
|
|
932
|
+
if (pending)
|
|
933
|
+
return pending;
|
|
934
|
+
const promise = buildWorkspaceGitBundle(resolvedCwd, gitRoot, includeNested, undefined, options)
|
|
935
|
+
.then((bundle) => {
|
|
936
|
+
gitBundleCache.set(cacheKey, { bundle, expiresAt: Date.now() + GIT_BUNDLE_CACHE_TTL_MS });
|
|
937
|
+
return bundle;
|
|
938
|
+
})
|
|
939
|
+
.finally(() => {
|
|
940
|
+
if (gitBundleBuildPromises.get(cacheKey) === promise)
|
|
941
|
+
gitBundleBuildPromises.delete(cacheKey);
|
|
942
|
+
});
|
|
943
|
+
gitBundleBuildPromises.set(cacheKey, promise);
|
|
944
|
+
return promise;
|
|
945
|
+
}
|
|
946
|
+
function getGitBundleCache(gitRoot, includeNested, allowStale = false) {
|
|
947
|
+
const cacheKey = getGitBundleCacheKey(gitRoot, includeNested);
|
|
948
|
+
const cached = gitBundleCache.get(cacheKey);
|
|
949
|
+
if (!cached)
|
|
950
|
+
return null;
|
|
951
|
+
const now = Date.now();
|
|
952
|
+
if (cached.expiresAt <= now && !allowStale)
|
|
953
|
+
return null;
|
|
954
|
+
const cacheAgeMs = Math.max(0, now - (cached.expiresAt - GIT_BUNDLE_CACHE_TTL_MS));
|
|
955
|
+
return {
|
|
956
|
+
...cached.bundle,
|
|
957
|
+
cached: true,
|
|
958
|
+
stale: cached.expiresAt <= now,
|
|
959
|
+
cacheAgeMs,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function getSinglePath(paths) {
|
|
963
|
+
if (!Array.isArray(paths) || paths.length !== 1 || typeof paths[0] !== 'string' || !paths[0]) {
|
|
964
|
+
throw new Error('Expected exactly one path');
|
|
965
|
+
}
|
|
966
|
+
return paths[0];
|
|
967
|
+
}
|
|
968
|
+
function getStashMessage(message, fallback) {
|
|
969
|
+
return typeof message === 'string' && message.trim() ? message.trim().slice(0, 160) : fallback;
|
|
970
|
+
}
|
|
971
|
+
function getCommitMessage(message) {
|
|
972
|
+
if (typeof message !== 'string' || !message.trim()) {
|
|
973
|
+
throw new Error('Commit message is required');
|
|
974
|
+
}
|
|
975
|
+
return message.trim().slice(0, 300);
|
|
976
|
+
}
|
|
977
|
+
function getRemoteName(remote) {
|
|
978
|
+
if (typeof remote !== 'string' || !remote.trim())
|
|
979
|
+
return undefined;
|
|
980
|
+
const normalized = remote.trim();
|
|
981
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(normalized)) {
|
|
982
|
+
throw new Error('Invalid remote name');
|
|
983
|
+
}
|
|
984
|
+
return normalized;
|
|
985
|
+
}
|
|
986
|
+
function getBranchName(branch) {
|
|
987
|
+
if (typeof branch !== 'string' || !branch.trim())
|
|
988
|
+
return undefined;
|
|
989
|
+
const normalized = branch.trim();
|
|
990
|
+
if (normalized.startsWith('-') || normalized.includes('..') || /[\s~^:?*\[\\]/.test(normalized)) {
|
|
991
|
+
throw new Error('Invalid branch name');
|
|
992
|
+
}
|
|
993
|
+
return normalized;
|
|
994
|
+
}
|
|
995
|
+
function getGitActionJobKey(gitRoot, action) {
|
|
996
|
+
return `${gitRoot}\0${action}`;
|
|
997
|
+
}
|
|
998
|
+
function serializeGitActionJob(job) {
|
|
999
|
+
return {
|
|
1000
|
+
jobId: job.id,
|
|
1001
|
+
status: job.status,
|
|
1002
|
+
action: job.action,
|
|
1003
|
+
cwd: job.cwd,
|
|
1004
|
+
gitRoot: job.gitRoot,
|
|
1005
|
+
startedAt: job.startedAt,
|
|
1006
|
+
finishedAt: job.finishedAt,
|
|
1007
|
+
message: job.message,
|
|
1008
|
+
output: job.output,
|
|
1009
|
+
error: job.error,
|
|
1010
|
+
code: job.code,
|
|
1011
|
+
bundle: job.bundle,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async function runGitActionCommand(body, gitRoot) {
|
|
1015
|
+
const { action, paths, message, confirm } = body;
|
|
1016
|
+
if (!action)
|
|
1017
|
+
throw new Error('Unsupported git action');
|
|
1018
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1019
|
+
if (action === 'stage-all') {
|
|
1020
|
+
return execGit(['add', '-A'], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1021
|
+
}
|
|
1022
|
+
if (action === 'stash-all') {
|
|
1023
|
+
const stashMessage = getStashMessage(message, `Termdock stash all ${now}`);
|
|
1024
|
+
return execGit(['stash', 'push', '--include-untracked', '-m', stashMessage], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1025
|
+
}
|
|
1026
|
+
if (action === 'commit') {
|
|
1027
|
+
return execGit(['commit', '-m', getCommitMessage(message)], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1028
|
+
}
|
|
1029
|
+
if (action === 'push') {
|
|
1030
|
+
const remote = getRemoteName(body.remote);
|
|
1031
|
+
const branch = getBranchName(body.branch);
|
|
1032
|
+
const pushArgs = remote && branch ? ['push', '-u', remote, branch] : remote ? ['push', remote] : ['push'];
|
|
1033
|
+
return execGit(pushArgs, gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1034
|
+
}
|
|
1035
|
+
if (action === 'pull') {
|
|
1036
|
+
const remote = getRemoteName(body.remote);
|
|
1037
|
+
const branch = getBranchName(body.branch);
|
|
1038
|
+
const pullArgs = remote && branch ? ['pull', '--ff-only', remote, branch] : remote ? ['pull', '--ff-only', remote] : ['pull', '--ff-only'];
|
|
1039
|
+
return execGit(pullArgs, gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1040
|
+
}
|
|
1041
|
+
if (action === 'switch-branch') {
|
|
1042
|
+
const branch = getBranchName(body.branch);
|
|
1043
|
+
if (!branch)
|
|
1044
|
+
throw new Error('Branch is required');
|
|
1045
|
+
return execGit(['switch', branch], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1046
|
+
}
|
|
1047
|
+
const requestedPath = getSinglePath(paths);
|
|
1048
|
+
const pathspec = await toGitPathspec(gitRoot, requestedPath);
|
|
1049
|
+
if (action === 'stage-file') {
|
|
1050
|
+
return execGit(['--literal-pathspecs', 'add', '--', pathspec], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1051
|
+
}
|
|
1052
|
+
if (action === 'unstage-file') {
|
|
1053
|
+
return execGit(['--literal-pathspecs', 'restore', '--staged', '--', pathspec], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1054
|
+
}
|
|
1055
|
+
if (action === 'stash-file') {
|
|
1056
|
+
const stashMessage = getStashMessage(message, `Termdock stash ${pathspec} ${now}`);
|
|
1057
|
+
return execGit(['--literal-pathspecs', 'stash', 'push', '--include-untracked', '-m', stashMessage, '--', pathspec], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1058
|
+
}
|
|
1059
|
+
if (action === 'restore-worktree-file') {
|
|
1060
|
+
if (!confirm?.acknowledged || !RESTORE_CONFIRM_PHRASES.has((confirm.phrase ?? '').trim())) {
|
|
1061
|
+
const error = new Error('Confirmation required before discarding changes');
|
|
1062
|
+
error.code = 'CONFIRMATION_REQUIRED';
|
|
1063
|
+
error.confirmationPhrase = '丢弃改动';
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
return execGit(['--literal-pathspecs', 'restore', '--worktree', '--', pathspec], gitRoot, undefined, GIT_ACTION_TIMEOUT_MS);
|
|
1067
|
+
}
|
|
1068
|
+
throw new Error('Unsupported git action');
|
|
1069
|
+
}
|
|
1070
|
+
async function readBytesPrefix(filePath, bytesToRead) {
|
|
1071
|
+
if (bytesToRead <= 0)
|
|
1072
|
+
return Buffer.alloc(0);
|
|
1073
|
+
const handle = await fs.promises.open(filePath, 'r');
|
|
1074
|
+
try {
|
|
1075
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
1076
|
+
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
|
|
1077
|
+
return buffer.subarray(0, bytesRead);
|
|
1078
|
+
}
|
|
1079
|
+
finally {
|
|
1080
|
+
await handle.close();
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function normalizeSearchQuery(query) {
|
|
1084
|
+
return typeof query === 'string' ? query.trim().slice(0, 200) : '';
|
|
1085
|
+
}
|
|
1086
|
+
function toSearchPath(filePath) {
|
|
1087
|
+
return filePath.split(path.sep).join('/').toLowerCase();
|
|
1088
|
+
}
|
|
1089
|
+
function searchEntryMatches(rootPath, candidatePath, queryLower) {
|
|
1090
|
+
if (!queryLower)
|
|
1091
|
+
return false;
|
|
1092
|
+
const name = path.basename(candidatePath).toLowerCase();
|
|
1093
|
+
const relative = path.relative(rootPath, candidatePath) || name;
|
|
1094
|
+
return name.includes(queryLower) || toSearchPath(relative).includes(queryLower) || toSearchPath(candidatePath).includes(queryLower);
|
|
1095
|
+
}
|
|
1096
|
+
function addSearchEntry(entries, entryPath, type) {
|
|
1097
|
+
if (entries.has(entryPath))
|
|
1098
|
+
return;
|
|
1099
|
+
entries.set(entryPath, {
|
|
1100
|
+
name: path.basename(entryPath) || entryPath,
|
|
1101
|
+
path: entryPath,
|
|
1102
|
+
type,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
function toFileEntry(entryPath, stat, isSymlink = false) {
|
|
1106
|
+
return {
|
|
1107
|
+
name: path.basename(entryPath) || entryPath,
|
|
1108
|
+
path: entryPath,
|
|
1109
|
+
type: stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file',
|
|
1110
|
+
isSymlink,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
async function toDirectoryEntry(dir, dirent) {
|
|
1114
|
+
const entryPath = path.join(dir, dirent.name);
|
|
1115
|
+
if (!dirent.isSymbolicLink()) {
|
|
1116
|
+
return {
|
|
1117
|
+
name: dirent.name,
|
|
1118
|
+
path: entryPath,
|
|
1119
|
+
type: dirent.isDirectory() ? 'directory' : 'file',
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
try {
|
|
1123
|
+
const stat = await fs.promises.stat(entryPath);
|
|
1124
|
+
return {
|
|
1125
|
+
name: dirent.name,
|
|
1126
|
+
path: entryPath,
|
|
1127
|
+
type: stat.isDirectory() ? 'directory' : 'symlink',
|
|
1128
|
+
isSymlink: true,
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
catch {
|
|
1132
|
+
return {
|
|
1133
|
+
name: dirent.name,
|
|
1134
|
+
path: entryPath,
|
|
1135
|
+
type: 'symlink',
|
|
1136
|
+
isSymlink: true,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function isIgnoredWatchPath(rootPath, candidatePath) {
|
|
1141
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
1142
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
1143
|
+
return false;
|
|
1144
|
+
return relative.split(path.sep).some((part) => WATCH_IGNORED_NAMES.has(part));
|
|
1145
|
+
}
|
|
1146
|
+
function sortSearchEntries(entries) {
|
|
1147
|
+
return entries.sort((a, b) => {
|
|
1148
|
+
if (a.type === 'directory' && b.type !== 'directory')
|
|
1149
|
+
return -1;
|
|
1150
|
+
if (a.type !== 'directory' && b.type === 'directory')
|
|
1151
|
+
return 1;
|
|
1152
|
+
return a.path.localeCompare(b.path);
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
function addMatchingParentDirectories(rootPath, absoluteFilePath, queryLower, entries) {
|
|
1156
|
+
let current = path.dirname(absoluteFilePath);
|
|
1157
|
+
while (current && current !== rootPath && isPathInside(rootPath, current)) {
|
|
1158
|
+
if (searchEntryMatches(rootPath, current, queryLower)) {
|
|
1159
|
+
addSearchEntry(entries, current, 'directory');
|
|
1160
|
+
}
|
|
1161
|
+
const parent = path.dirname(current);
|
|
1162
|
+
if (parent === current)
|
|
1163
|
+
break;
|
|
1164
|
+
current = parent;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
function searchWithRipgrep(rootPath, queryLower, showHidden, signal) {
|
|
1168
|
+
return new Promise((resolve, reject) => {
|
|
1169
|
+
const args = ['--files', '--color', 'never', '--no-messages', '--null'];
|
|
1170
|
+
if (showHidden)
|
|
1171
|
+
args.push('--hidden', '-g', '!.git/');
|
|
1172
|
+
const proc = spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1173
|
+
const entries = new Map();
|
|
1174
|
+
let stdout = '';
|
|
1175
|
+
let stderr = '';
|
|
1176
|
+
let settled = false;
|
|
1177
|
+
const finish = (fn) => {
|
|
1178
|
+
if (settled)
|
|
1179
|
+
return;
|
|
1180
|
+
settled = true;
|
|
1181
|
+
signal.removeEventListener('abort', abortHandler);
|
|
1182
|
+
fn();
|
|
1183
|
+
};
|
|
1184
|
+
const abortHandler = () => {
|
|
1185
|
+
proc.kill('SIGTERM');
|
|
1186
|
+
finish(() => reject(new Error('Search aborted')));
|
|
1187
|
+
};
|
|
1188
|
+
signal.addEventListener('abort', abortHandler);
|
|
1189
|
+
proc.stdout.setEncoding('utf8');
|
|
1190
|
+
proc.stdout.on('data', (chunk) => {
|
|
1191
|
+
stdout += chunk;
|
|
1192
|
+
let boundary = stdout.lastIndexOf('\0');
|
|
1193
|
+
if (boundary < 0)
|
|
1194
|
+
return;
|
|
1195
|
+
const complete = stdout.slice(0, boundary);
|
|
1196
|
+
stdout = stdout.slice(boundary + 1);
|
|
1197
|
+
for (const relativePath of complete.split('\0')) {
|
|
1198
|
+
if (!relativePath)
|
|
1199
|
+
continue;
|
|
1200
|
+
const absolutePath = path.join(rootPath, relativePath);
|
|
1201
|
+
if (!searchEntryMatches(rootPath, absolutePath, queryLower)) {
|
|
1202
|
+
addMatchingParentDirectories(rootPath, absolutePath, queryLower, entries);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
addSearchEntry(entries, absolutePath, 'file');
|
|
1206
|
+
addMatchingParentDirectories(rootPath, absolutePath, queryLower, entries);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
proc.stderr.setEncoding('utf8');
|
|
1210
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1211
|
+
proc.on('error', (error) => finish(() => reject(error)));
|
|
1212
|
+
proc.on('close', (code) => {
|
|
1213
|
+
if (settled)
|
|
1214
|
+
return;
|
|
1215
|
+
if (code !== 0 && code !== 1) {
|
|
1216
|
+
finish(() => reject(new Error(stderr.trim() || `rg exited with code ${code}`)));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
finish(() => resolve({
|
|
1220
|
+
path: rootPath,
|
|
1221
|
+
query: queryLower,
|
|
1222
|
+
entries: sortSearchEntries(Array.from(entries.values())),
|
|
1223
|
+
truncated: false,
|
|
1224
|
+
total: entries.size,
|
|
1225
|
+
engine: 'rg',
|
|
1226
|
+
}));
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
async function searchWithFallback(rootPath, queryLower, showHidden, signal) {
|
|
1231
|
+
const entries = new Map();
|
|
1232
|
+
const queue = [rootPath];
|
|
1233
|
+
let visited = 0;
|
|
1234
|
+
while (queue.length > 0 && visited < MAX_FALLBACK_SEARCH_VISITED) {
|
|
1235
|
+
if (signal.aborted)
|
|
1236
|
+
throw new Error('Search aborted');
|
|
1237
|
+
const dirPath = queue.shift();
|
|
1238
|
+
if (!dirPath)
|
|
1239
|
+
continue;
|
|
1240
|
+
visited += 1;
|
|
1241
|
+
let dirents;
|
|
1242
|
+
try {
|
|
1243
|
+
dirents = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
for (const dirent of dirents) {
|
|
1249
|
+
if (!showHidden && dirent.name.startsWith('.'))
|
|
1250
|
+
continue;
|
|
1251
|
+
if (dirent.name === '.git')
|
|
1252
|
+
continue;
|
|
1253
|
+
const fullPath = path.join(dirPath, dirent.name);
|
|
1254
|
+
const type = dirent.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file';
|
|
1255
|
+
if (searchEntryMatches(rootPath, fullPath, queryLower)) {
|
|
1256
|
+
addSearchEntry(entries, fullPath, type);
|
|
1257
|
+
}
|
|
1258
|
+
if (dirent.isDirectory())
|
|
1259
|
+
queue.push(fullPath);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
path: rootPath,
|
|
1264
|
+
query: queryLower,
|
|
1265
|
+
entries: sortSearchEntries(Array.from(entries.values())),
|
|
1266
|
+
truncated: visited >= MAX_FALLBACK_SEARCH_VISITED,
|
|
1267
|
+
total: entries.size,
|
|
1268
|
+
engine: 'fallback',
|
|
1269
|
+
limited: visited >= MAX_FALLBACK_SEARCH_VISITED,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function writeSearchEvent(res, type, payload) {
|
|
1273
|
+
res.write(`${JSON.stringify({ type, ...payload })}\n`);
|
|
1274
|
+
}
|
|
1275
|
+
function createSearchBatchEmitter(res) {
|
|
1276
|
+
let batch = [];
|
|
1277
|
+
const flush = () => {
|
|
1278
|
+
if (batch.length === 0 || res.destroyed)
|
|
1279
|
+
return;
|
|
1280
|
+
writeSearchEvent(res, 'batch', { entries: batch });
|
|
1281
|
+
batch = [];
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
push(entry) {
|
|
1285
|
+
batch.push(entry);
|
|
1286
|
+
if (batch.length >= 60)
|
|
1287
|
+
flush();
|
|
1288
|
+
},
|
|
1289
|
+
flush,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
function streamSearchWithRipgrep(rootPath, queryLower, showHidden, signal, res) {
|
|
1293
|
+
return new Promise((resolve, reject) => {
|
|
1294
|
+
const args = ['--files', '--color', 'never', '--no-messages', '--null'];
|
|
1295
|
+
if (showHidden)
|
|
1296
|
+
args.push('--hidden', '-g', '!.git/');
|
|
1297
|
+
const proc = spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1298
|
+
const emitted = new Map();
|
|
1299
|
+
const batch = createSearchBatchEmitter(res);
|
|
1300
|
+
let stdout = '';
|
|
1301
|
+
let stderr = '';
|
|
1302
|
+
let settled = false;
|
|
1303
|
+
const emitEntry = (entryPath, type) => {
|
|
1304
|
+
if (emitted.has(entryPath))
|
|
1305
|
+
return;
|
|
1306
|
+
const entry = { name: path.basename(entryPath) || entryPath, path: entryPath, type };
|
|
1307
|
+
emitted.set(entryPath, entry);
|
|
1308
|
+
batch.push(entry);
|
|
1309
|
+
};
|
|
1310
|
+
const emitMatchingParents = (absoluteFilePath) => {
|
|
1311
|
+
let current = path.dirname(absoluteFilePath);
|
|
1312
|
+
while (current && current !== rootPath && isPathInside(rootPath, current)) {
|
|
1313
|
+
if (searchEntryMatches(rootPath, current, queryLower))
|
|
1314
|
+
emitEntry(current, 'directory');
|
|
1315
|
+
const parent = path.dirname(current);
|
|
1316
|
+
if (parent === current)
|
|
1317
|
+
break;
|
|
1318
|
+
current = parent;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
const finish = (fn) => {
|
|
1322
|
+
if (settled)
|
|
1323
|
+
return;
|
|
1324
|
+
settled = true;
|
|
1325
|
+
signal.removeEventListener('abort', abortHandler);
|
|
1326
|
+
batch.flush();
|
|
1327
|
+
fn();
|
|
1328
|
+
};
|
|
1329
|
+
const abortHandler = () => {
|
|
1330
|
+
proc.kill('SIGTERM');
|
|
1331
|
+
finish(() => reject(new Error('Search aborted')));
|
|
1332
|
+
};
|
|
1333
|
+
signal.addEventListener('abort', abortHandler);
|
|
1334
|
+
proc.stdout.setEncoding('utf8');
|
|
1335
|
+
proc.stdout.on('data', (chunk) => {
|
|
1336
|
+
stdout += chunk;
|
|
1337
|
+
const boundary = stdout.lastIndexOf('\0');
|
|
1338
|
+
if (boundary < 0)
|
|
1339
|
+
return;
|
|
1340
|
+
const complete = stdout.slice(0, boundary);
|
|
1341
|
+
stdout = stdout.slice(boundary + 1);
|
|
1342
|
+
for (const relativePath of complete.split('\0')) {
|
|
1343
|
+
if (!relativePath)
|
|
1344
|
+
continue;
|
|
1345
|
+
const absolutePath = path.join(rootPath, relativePath);
|
|
1346
|
+
if (!searchEntryMatches(rootPath, absolutePath, queryLower)) {
|
|
1347
|
+
emitMatchingParents(absolutePath);
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
emitEntry(absolutePath, 'file');
|
|
1351
|
+
emitMatchingParents(absolutePath);
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
proc.stderr.setEncoding('utf8');
|
|
1355
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1356
|
+
proc.on('error', (error) => finish(() => reject(error)));
|
|
1357
|
+
proc.on('close', (code) => {
|
|
1358
|
+
if (settled)
|
|
1359
|
+
return;
|
|
1360
|
+
if (code !== 0 && code !== 1) {
|
|
1361
|
+
finish(() => reject(new Error(stderr.trim() || `rg exited with code ${code}`)));
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
finish(() => resolve(emitted.size));
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
async function streamSearchWithFallback(rootPath, queryLower, showHidden, signal, res) {
|
|
1369
|
+
const emitted = new Set();
|
|
1370
|
+
const batch = createSearchBatchEmitter(res);
|
|
1371
|
+
const queue = [rootPath];
|
|
1372
|
+
let visited = 0;
|
|
1373
|
+
const emitEntry = (entryPath, type) => {
|
|
1374
|
+
if (emitted.has(entryPath))
|
|
1375
|
+
return;
|
|
1376
|
+
emitted.add(entryPath);
|
|
1377
|
+
batch.push({ name: path.basename(entryPath) || entryPath, path: entryPath, type });
|
|
1378
|
+
};
|
|
1379
|
+
while (queue.length > 0 && visited < MAX_FALLBACK_SEARCH_VISITED) {
|
|
1380
|
+
if (signal.aborted || res.destroyed)
|
|
1381
|
+
throw new Error('Search aborted');
|
|
1382
|
+
const dirPath = queue.shift();
|
|
1383
|
+
if (!dirPath)
|
|
1384
|
+
continue;
|
|
1385
|
+
visited += 1;
|
|
1386
|
+
let dirents;
|
|
1387
|
+
try {
|
|
1388
|
+
dirents = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
1389
|
+
}
|
|
1390
|
+
catch {
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
for (const dirent of dirents) {
|
|
1394
|
+
if (!showHidden && dirent.name.startsWith('.'))
|
|
1395
|
+
continue;
|
|
1396
|
+
if (dirent.name === '.git')
|
|
1397
|
+
continue;
|
|
1398
|
+
const fullPath = path.join(dirPath, dirent.name);
|
|
1399
|
+
const type = dirent.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file';
|
|
1400
|
+
if (searchEntryMatches(rootPath, fullPath, queryLower)) {
|
|
1401
|
+
emitEntry(fullPath, type);
|
|
1402
|
+
}
|
|
1403
|
+
if (dirent.isDirectory())
|
|
1404
|
+
queue.push(fullPath);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
batch.flush();
|
|
1408
|
+
return { total: emitted.size, limited: visited >= MAX_FALLBACK_SEARCH_VISITED };
|
|
1409
|
+
}
|
|
1410
|
+
function createContentBatchEmitter(res) {
|
|
1411
|
+
let batch = [];
|
|
1412
|
+
const flush = () => {
|
|
1413
|
+
if (batch.length === 0 || res.destroyed)
|
|
1414
|
+
return;
|
|
1415
|
+
writeSearchEvent(res, 'content-batch', { contentEntries: batch });
|
|
1416
|
+
batch = [];
|
|
1417
|
+
};
|
|
1418
|
+
return {
|
|
1419
|
+
push(entry) {
|
|
1420
|
+
batch.push(entry);
|
|
1421
|
+
if (batch.length >= 20)
|
|
1422
|
+
flush();
|
|
1423
|
+
},
|
|
1424
|
+
flush,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
// Full-text search using ripgrep's JSON output. Results are aggregated per
|
|
1428
|
+
// file (path + matching lines) and streamed in small batches. Unlike the
|
|
1429
|
+
// file-name search there is no fallback engine: a recursive content grep
|
|
1430
|
+
// without ripgrep would be far too expensive, so callers get an explicit
|
|
1431
|
+
// "ripgrep required" error instead.
|
|
1432
|
+
function streamContentSearchWithRipgrep(rootPath, query, showHidden, signal, res) {
|
|
1433
|
+
return new Promise((resolve, reject) => {
|
|
1434
|
+
const args = [
|
|
1435
|
+
'--json',
|
|
1436
|
+
'--smart-case',
|
|
1437
|
+
'--no-messages',
|
|
1438
|
+
'-m', String(MAX_CONTENT_MATCHES_PER_FILE),
|
|
1439
|
+
];
|
|
1440
|
+
if (showHidden)
|
|
1441
|
+
args.push('--hidden', '-g', '!.git/');
|
|
1442
|
+
args.push('--', query);
|
|
1443
|
+
const proc = spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1444
|
+
const batch = createContentBatchEmitter(res);
|
|
1445
|
+
let stdout = '';
|
|
1446
|
+
let stderr = '';
|
|
1447
|
+
let settled = false;
|
|
1448
|
+
let fileCount = 0;
|
|
1449
|
+
let limited = false;
|
|
1450
|
+
// Buffer matches per file until rg emits the file's "end" event.
|
|
1451
|
+
let currentPath = null;
|
|
1452
|
+
let currentMatches = [];
|
|
1453
|
+
const finish = (fn) => {
|
|
1454
|
+
if (settled)
|
|
1455
|
+
return;
|
|
1456
|
+
settled = true;
|
|
1457
|
+
signal.removeEventListener('abort', abortHandler);
|
|
1458
|
+
batch.flush();
|
|
1459
|
+
fn();
|
|
1460
|
+
};
|
|
1461
|
+
const abortHandler = () => {
|
|
1462
|
+
proc.kill('SIGTERM');
|
|
1463
|
+
finish(() => reject(new Error('Search aborted')));
|
|
1464
|
+
};
|
|
1465
|
+
signal.addEventListener('abort', abortHandler);
|
|
1466
|
+
const flushCurrentFile = () => {
|
|
1467
|
+
if (currentPath && currentMatches.length > 0) {
|
|
1468
|
+
if (fileCount >= MAX_CONTENT_SEARCH_FILES) {
|
|
1469
|
+
limited = true;
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
fileCount += 1;
|
|
1473
|
+
batch.push({ name: path.basename(currentPath) || currentPath, path: currentPath, matches: currentMatches });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
currentPath = null;
|
|
1477
|
+
currentMatches = [];
|
|
1478
|
+
};
|
|
1479
|
+
const handleEvent = (raw) => {
|
|
1480
|
+
if (!raw.trim())
|
|
1481
|
+
return;
|
|
1482
|
+
let event;
|
|
1483
|
+
try {
|
|
1484
|
+
event = JSON.parse(raw);
|
|
1485
|
+
}
|
|
1486
|
+
catch {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (event.type === 'begin') {
|
|
1490
|
+
const text = event.data?.path?.text;
|
|
1491
|
+
currentPath = typeof text === 'string' ? path.resolve(rootPath, text) : null;
|
|
1492
|
+
currentMatches = [];
|
|
1493
|
+
}
|
|
1494
|
+
else if (event.type === 'match') {
|
|
1495
|
+
if (!currentPath)
|
|
1496
|
+
return;
|
|
1497
|
+
if (currentMatches.length >= MAX_CONTENT_MATCHES_PER_FILE)
|
|
1498
|
+
return;
|
|
1499
|
+
const lineNumber = typeof event.data?.line_number === 'number' ? event.data.line_number : null;
|
|
1500
|
+
const lineTextRaw = event.data?.lines?.text;
|
|
1501
|
+
if (lineNumber === null || typeof lineTextRaw !== 'string')
|
|
1502
|
+
return;
|
|
1503
|
+
const trimmed = lineTextRaw.replace(/\r?\n$/, '');
|
|
1504
|
+
const text = trimmed.length > MAX_CONTENT_MATCH_LINE_LENGTH
|
|
1505
|
+
? `${trimmed.slice(0, MAX_CONTENT_MATCH_LINE_LENGTH)}…`
|
|
1506
|
+
: trimmed;
|
|
1507
|
+
currentMatches.push({ line: lineNumber, text });
|
|
1508
|
+
}
|
|
1509
|
+
else if (event.type === 'end') {
|
|
1510
|
+
flushCurrentFile();
|
|
1511
|
+
if (limited) {
|
|
1512
|
+
proc.kill('SIGTERM');
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
proc.stdout.setEncoding('utf8');
|
|
1517
|
+
proc.stdout.on('data', (chunk) => {
|
|
1518
|
+
stdout += chunk;
|
|
1519
|
+
let newlineIndex = stdout.indexOf('\n');
|
|
1520
|
+
while (newlineIndex >= 0) {
|
|
1521
|
+
handleEvent(stdout.slice(0, newlineIndex));
|
|
1522
|
+
stdout = stdout.slice(newlineIndex + 1);
|
|
1523
|
+
newlineIndex = stdout.indexOf('\n');
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
proc.stderr.setEncoding('utf8');
|
|
1527
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1528
|
+
proc.on('error', (error) => finish(() => reject(error)));
|
|
1529
|
+
proc.on('close', (code) => {
|
|
1530
|
+
if (settled)
|
|
1531
|
+
return;
|
|
1532
|
+
// rg exits 1 when there are no matches (not an error here) and is killed
|
|
1533
|
+
// (null code) once we hit the file cap; treat >=2 as a real failure.
|
|
1534
|
+
if (code !== null && code !== 0 && code !== 1) {
|
|
1535
|
+
finish(() => reject(new Error(stderr.trim() || `rg exited with code ${code}`)));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
flushCurrentFile();
|
|
1539
|
+
finish(() => resolve({ total: fileCount, limited }));
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
function execGit(args, cwd, signal, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1544
|
+
return new Promise((resolve, reject) => {
|
|
1545
|
+
let settled = false;
|
|
1546
|
+
const timer = timeoutMs === null ? null : setTimeout(() => {
|
|
1547
|
+
if (settled)
|
|
1548
|
+
return;
|
|
1549
|
+
settled = true;
|
|
1550
|
+
proc.kill();
|
|
1551
|
+
reject(new Error('git command timed out'));
|
|
1552
|
+
}, timeoutMs);
|
|
1553
|
+
const proc = execFile('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
1554
|
+
if (settled)
|
|
1555
|
+
return;
|
|
1556
|
+
settled = true;
|
|
1557
|
+
if (timer)
|
|
1558
|
+
clearTimeout(timer);
|
|
1559
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
1560
|
+
if (err) {
|
|
1561
|
+
reject(err);
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
resolve(stdout);
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
const abortHandler = () => {
|
|
1568
|
+
if (settled)
|
|
1569
|
+
return;
|
|
1570
|
+
settled = true;
|
|
1571
|
+
if (timer)
|
|
1572
|
+
clearTimeout(timer);
|
|
1573
|
+
proc.kill();
|
|
1574
|
+
const reason = signal?.reason;
|
|
1575
|
+
reject(reason instanceof Error ? reason : new GitCommandAbortError());
|
|
1576
|
+
};
|
|
1577
|
+
if (signal) {
|
|
1578
|
+
if (signal.aborted) {
|
|
1579
|
+
abortHandler();
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
function execGitLimited(args, cwd, maxBytes, allowExitCodeOne = false, signal, logContext) {
|
|
1587
|
+
return new Promise((resolve, reject) => {
|
|
1588
|
+
const chunks = [];
|
|
1589
|
+
let totalBytes = 0;
|
|
1590
|
+
let stderr = '';
|
|
1591
|
+
let settled = false;
|
|
1592
|
+
let truncated = false;
|
|
1593
|
+
const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1594
|
+
if (logContext) {
|
|
1595
|
+
logFsIoEvent({ ...logContext, event: 'git-child-start', cwd, childPid: proc.pid, args });
|
|
1596
|
+
}
|
|
1597
|
+
const timer = setTimeout(() => {
|
|
1598
|
+
if (settled)
|
|
1599
|
+
return;
|
|
1600
|
+
settled = true;
|
|
1601
|
+
proc.kill();
|
|
1602
|
+
if (logContext) {
|
|
1603
|
+
logFsIoEvent({ ...logContext, event: 'git-child-timeout-kill', cwd, childPid: proc.pid, args, code: 'GIT_CHILD_TIMEOUT' });
|
|
1604
|
+
}
|
|
1605
|
+
reject(new Error('git command timed out'));
|
|
1606
|
+
}, GIT_TIMEOUT_MS);
|
|
1607
|
+
const finish = (fn) => {
|
|
1608
|
+
if (settled)
|
|
1609
|
+
return;
|
|
1610
|
+
settled = true;
|
|
1611
|
+
clearTimeout(timer);
|
|
1612
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
1613
|
+
fn();
|
|
1614
|
+
};
|
|
1615
|
+
const abortHandler = () => {
|
|
1616
|
+
proc.kill();
|
|
1617
|
+
if (logContext) {
|
|
1618
|
+
logFsIoEvent({ ...logContext, event: 'git-child-abort-kill', cwd, childPid: proc.pid, args, code: 'GIT_CHILD_ABORTED' });
|
|
1619
|
+
}
|
|
1620
|
+
const reason = signal?.reason;
|
|
1621
|
+
finish(() => reject(reason instanceof Error ? reason : new GitCommandAbortError()));
|
|
1622
|
+
};
|
|
1623
|
+
if (signal) {
|
|
1624
|
+
if (signal.aborted) {
|
|
1625
|
+
abortHandler();
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
1629
|
+
}
|
|
1630
|
+
proc.stdout.on('data', (chunk) => {
|
|
1631
|
+
if (truncated)
|
|
1632
|
+
return;
|
|
1633
|
+
const remainingBytes = maxBytes - totalBytes;
|
|
1634
|
+
if (chunk.length > remainingBytes) {
|
|
1635
|
+
if (remainingBytes > 0) {
|
|
1636
|
+
chunks.push(chunk.subarray(0, remainingBytes));
|
|
1637
|
+
totalBytes += remainingBytes;
|
|
1638
|
+
}
|
|
1639
|
+
truncated = true;
|
|
1640
|
+
if (logContext) {
|
|
1641
|
+
logFsIoEvent({ ...logContext, event: 'git-child-byte-limit-kill', cwd, childPid: proc.pid, args, code: 'GIT_CHILD_BYTE_LIMIT', extra: { maxBytes } });
|
|
1642
|
+
}
|
|
1643
|
+
proc.kill();
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
chunks.push(chunk);
|
|
1647
|
+
totalBytes += chunk.length;
|
|
1648
|
+
});
|
|
1649
|
+
proc.stderr.setEncoding('utf8');
|
|
1650
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1651
|
+
proc.on('error', (error) => finish(() => reject(error)));
|
|
1652
|
+
proc.on('close', (code) => {
|
|
1653
|
+
finish(() => {
|
|
1654
|
+
if (logContext) {
|
|
1655
|
+
logFsIoEvent({ ...logContext, event: 'git-child-close', cwd, childPid: proc.pid, args, extra: { exitCode: code, totalBytes, truncated } });
|
|
1656
|
+
}
|
|
1657
|
+
const allowed = code === 0 || (allowExitCodeOne && code === 1);
|
|
1658
|
+
if (!allowed && !truncated) {
|
|
1659
|
+
reject(new Error(stderr.trim() || `git exited with code ${code}`));
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
resolve({ stdout: Buffer.concat(chunks).toString('utf8'), truncated });
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
function isSafeGitRefName(ref) {
|
|
1668
|
+
if (!ref || ref.length > 240)
|
|
1669
|
+
return false;
|
|
1670
|
+
if (ref.startsWith('-') || ref.startsWith('/') || ref.endsWith('/') || ref.endsWith('.'))
|
|
1671
|
+
return false;
|
|
1672
|
+
if (ref.includes('..') || ref.includes('@{') || ref.includes('\\') || ref.includes('//'))
|
|
1673
|
+
return false;
|
|
1674
|
+
if (/[\s~^:?*[\\\]\0-\x1f\x7f]/.test(ref))
|
|
1675
|
+
return false;
|
|
1676
|
+
return true;
|
|
1677
|
+
}
|
|
1678
|
+
function normalizeDiffPath(value) {
|
|
1679
|
+
if (!value || value === '/dev/null')
|
|
1680
|
+
return null;
|
|
1681
|
+
return value.startsWith('a/') || value.startsWith('b/') ? value.slice(2) : value;
|
|
1682
|
+
}
|
|
1683
|
+
function buildHunkFingerprint(lines) {
|
|
1684
|
+
const changedLines = lines
|
|
1685
|
+
.filter((line) => (line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---')))
|
|
1686
|
+
.map((line) => `${line.startsWith('+') ? 'insert' : 'delete'}:${line.slice(1)}`);
|
|
1687
|
+
const text = changedLines.length > 0
|
|
1688
|
+
? changedLines.join('\n')
|
|
1689
|
+
: lines.map((line) => line.slice(1)).join('\n');
|
|
1690
|
+
let hash = 0x811c9dc5;
|
|
1691
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1692
|
+
hash ^= text.charCodeAt(i);
|
|
1693
|
+
hash = Math.imul(hash, 0x01000193);
|
|
1694
|
+
}
|
|
1695
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
1696
|
+
}
|
|
1697
|
+
function parseBranchDiffHunks(diffText) {
|
|
1698
|
+
const hunks = [];
|
|
1699
|
+
const lines = diffText.split('\n');
|
|
1700
|
+
let oldPath = null;
|
|
1701
|
+
let newPath = null;
|
|
1702
|
+
let currentHeader = null;
|
|
1703
|
+
let currentLines = [];
|
|
1704
|
+
let hunkIndexByFile = 0;
|
|
1705
|
+
const flush = () => {
|
|
1706
|
+
if (!currentHeader || (!oldPath && !newPath))
|
|
1707
|
+
return;
|
|
1708
|
+
const filePath = newPath ?? oldPath ?? '';
|
|
1709
|
+
const oldDiffPath = oldPath ? `a/${oldPath}` : '/dev/null';
|
|
1710
|
+
const newDiffPath = newPath ? `b/${newPath}` : '/dev/null';
|
|
1711
|
+
const hunkDiff = [
|
|
1712
|
+
`diff --git a/${oldPath ?? filePath} b/${newPath ?? filePath}`,
|
|
1713
|
+
`--- ${oldDiffPath}`,
|
|
1714
|
+
`+++ ${newDiffPath}`,
|
|
1715
|
+
currentHeader,
|
|
1716
|
+
...currentLines,
|
|
1717
|
+
].join('\n');
|
|
1718
|
+
hunks.push({
|
|
1719
|
+
filePath,
|
|
1720
|
+
oldPath,
|
|
1721
|
+
newPath,
|
|
1722
|
+
hunkHeader: currentHeader,
|
|
1723
|
+
hunkIndex: hunkIndexByFile,
|
|
1724
|
+
fingerprint: buildHunkFingerprint(currentLines),
|
|
1725
|
+
additions: currentLines.filter((line) => line.startsWith('+') && !line.startsWith('+++')).length,
|
|
1726
|
+
deletions: currentLines.filter((line) => line.startsWith('-') && !line.startsWith('---')).length,
|
|
1727
|
+
diff: hunkDiff,
|
|
1728
|
+
});
|
|
1729
|
+
hunkIndexByFile += 1;
|
|
1730
|
+
};
|
|
1731
|
+
for (const line of lines) {
|
|
1732
|
+
if (line.startsWith('diff --git ')) {
|
|
1733
|
+
flush();
|
|
1734
|
+
currentHeader = null;
|
|
1735
|
+
currentLines = [];
|
|
1736
|
+
hunkIndexByFile = 0;
|
|
1737
|
+
const match = /^diff --git (.+) (.+)$/.exec(line);
|
|
1738
|
+
oldPath = normalizeDiffPath(match?.[1] ?? '');
|
|
1739
|
+
newPath = normalizeDiffPath(match?.[2] ?? '');
|
|
1740
|
+
continue;
|
|
1741
|
+
}
|
|
1742
|
+
if (line.startsWith('--- ')) {
|
|
1743
|
+
oldPath = normalizeDiffPath(line.slice(4).trim());
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
if (line.startsWith('+++ ')) {
|
|
1747
|
+
newPath = normalizeDiffPath(line.slice(4).trim());
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (line.startsWith('@@ ')) {
|
|
1751
|
+
flush();
|
|
1752
|
+
currentHeader = line;
|
|
1753
|
+
currentLines = [];
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
if (currentHeader)
|
|
1757
|
+
currentLines.push(line);
|
|
1758
|
+
}
|
|
1759
|
+
flush();
|
|
1760
|
+
return hunks;
|
|
1761
|
+
}
|
|
1762
|
+
async function annotateBranchDiffHunks(repoRoot, baseRef, hunks, signal) {
|
|
1763
|
+
if (hunks.length === 0)
|
|
1764
|
+
return hunks;
|
|
1765
|
+
const workingDiff = await execGitLimited(['diff', 'HEAD'], repoRoot, MAX_BRANCH_DIFF_BYTES, false, signal)
|
|
1766
|
+
.then((result) => result.stdout)
|
|
1767
|
+
.catch(emptyOnNonAbortGitError);
|
|
1768
|
+
const workingFingerprints = new Set(parseBranchDiffHunks(workingDiff).map((hunk) => `${hunk.filePath}\0${hunk.hunkHeader}\0${hunk.fingerprint}`));
|
|
1769
|
+
const commitByFile = new Map();
|
|
1770
|
+
await Promise.all(Array.from(new Set(hunks.map((hunk) => hunk.filePath))).map(async (filePath) => {
|
|
1771
|
+
const commit = await execGit(['log', '-1', '--format=%h', `${baseRef}..HEAD`, '--', filePath], repoRoot, signal)
|
|
1772
|
+
.then((result) => result.trim() || null)
|
|
1773
|
+
.catch(() => null);
|
|
1774
|
+
commitByFile.set(filePath, commit);
|
|
1775
|
+
}));
|
|
1776
|
+
return hunks.map((hunk) => {
|
|
1777
|
+
const key = `${hunk.filePath}\0${hunk.hunkHeader}\0${hunk.fingerprint}`;
|
|
1778
|
+
if (workingFingerprints.has(key))
|
|
1779
|
+
return { ...hunk, source: 'uncommitted', commit: null };
|
|
1780
|
+
const commit = commitByFile.get(hunk.filePath) ?? null;
|
|
1781
|
+
return { ...hunk, source: commit ? 'committed' : 'unknown', commit };
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
async function getBranchDiffPayload(workspaceRoot, repoRoot, baseBranch, options, signal) {
|
|
1785
|
+
const trimmedBase = baseBranch.trim();
|
|
1786
|
+
if (!isSafeGitRefName(trimmedBase)) {
|
|
1787
|
+
return { available: false, workspaceRoot, repoRoot, baseBranch: trimmedBase, error: 'Invalid base branch name' };
|
|
1788
|
+
}
|
|
1789
|
+
const requestedHead = options?.headRef?.trim() ?? '';
|
|
1790
|
+
const hasRequestedHead = requestedHead.length > 0;
|
|
1791
|
+
if (hasRequestedHead && !isSafeGitRefName(requestedHead)) {
|
|
1792
|
+
return { available: false, workspaceRoot, repoRoot, baseBranch: trimmedBase, error: 'Invalid target branch name' };
|
|
1793
|
+
}
|
|
1794
|
+
const includeUncommitted = options?.includeUncommitted ?? true;
|
|
1795
|
+
let baseRef = trimmedBase.includes('/') ? trimmedBase : `origin/${trimmedBase}`;
|
|
1796
|
+
if (trimmedBase.includes('/')) {
|
|
1797
|
+
await execGit(['rev-parse', '--verify', '--quiet', baseRef], repoRoot, signal);
|
|
1798
|
+
}
|
|
1799
|
+
else {
|
|
1800
|
+
try {
|
|
1801
|
+
await execGit(['fetch', 'origin', trimmedBase, '--no-tags'], repoRoot, signal, GIT_ROUTE_TIMEOUT_MS);
|
|
1802
|
+
}
|
|
1803
|
+
catch (error) {
|
|
1804
|
+
const localRef = await execGit(['rev-parse', '--verify', '--quiet', trimmedBase], repoRoot, signal)
|
|
1805
|
+
.then(() => trimmedBase)
|
|
1806
|
+
.catch(() => null);
|
|
1807
|
+
if (!localRef)
|
|
1808
|
+
throw error;
|
|
1809
|
+
baseRef = localRef;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
let compareHead = 'HEAD';
|
|
1813
|
+
if (hasRequestedHead) {
|
|
1814
|
+
compareHead = requestedHead.includes('/') ? requestedHead : requestedHead;
|
|
1815
|
+
await execGit(['rev-parse', '--verify', '--quiet', compareHead], repoRoot, signal);
|
|
1816
|
+
}
|
|
1817
|
+
const includeWorkingTree = includeUncommitted && !hasRequestedHead;
|
|
1818
|
+
const [currentBranch, headRef, statResult, nameResult, workingNameResult, logResult, diffResult, workingDiffResult] = await Promise.all([
|
|
1819
|
+
execGit(['branch', '--show-current'], repoRoot, signal).catch(emptyOnNonAbortGitError),
|
|
1820
|
+
execGit(['rev-parse', '--short=12', compareHead], repoRoot, signal).catch(emptyOnNonAbortGitError),
|
|
1821
|
+
execGitLimited(['diff', `${baseRef}...${compareHead}`, '--stat'], repoRoot, MAX_BRANCH_DIFF_STAT_BYTES, false, signal),
|
|
1822
|
+
execGitLimited(['diff', '--name-only', `${baseRef}...${compareHead}`], repoRoot, MAX_BRANCH_DIFF_NAME_BYTES, false, signal),
|
|
1823
|
+
includeWorkingTree ? execGitLimited(['diff', '--name-only', 'HEAD'], repoRoot, MAX_BRANCH_DIFF_NAME_BYTES, false, signal) : Promise.resolve({ stdout: '', truncated: false }),
|
|
1824
|
+
execGitLimited(['log', `${baseRef}..${compareHead}`, '--oneline', '--no-merges'], repoRoot, MAX_BRANCH_DIFF_LOG_BYTES, false, signal),
|
|
1825
|
+
execGitLimited(['diff', `${baseRef}...${compareHead}`], repoRoot, MAX_BRANCH_DIFF_BYTES, false, signal),
|
|
1826
|
+
includeWorkingTree ? execGitLimited(['diff', 'HEAD'], repoRoot, MAX_BRANCH_DIFF_BYTES, false, signal) : Promise.resolve({ stdout: '', truncated: false }),
|
|
1827
|
+
]);
|
|
1828
|
+
const trackedDiff = [diffResult.stdout, workingDiffResult.stdout].filter(Boolean).join('\n');
|
|
1829
|
+
const untrackedResult = includeWorkingTree
|
|
1830
|
+
? await appendUntrackedDiffs(repoRoot, trackedDiff, signal, {
|
|
1831
|
+
maxBytes: MAX_BRANCH_DIFF_BYTES,
|
|
1832
|
+
perFileMaxBytes: MAX_UNTRACKED_DIFF_FILE_BYTES,
|
|
1833
|
+
})
|
|
1834
|
+
: { diff: trackedDiff, files: [], skippedFiles: [], truncated: false };
|
|
1835
|
+
const diff = untrackedResult.diff;
|
|
1836
|
+
const stat = statResult.stdout.trim();
|
|
1837
|
+
const files = Array.from(new Set([
|
|
1838
|
+
...nameResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean),
|
|
1839
|
+
...workingNameResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean),
|
|
1840
|
+
...untrackedResult.files,
|
|
1841
|
+
]));
|
|
1842
|
+
const hunks = await annotateBranchDiffHunks(repoRoot, baseRef, parseBranchDiffHunks(diff), signal);
|
|
1843
|
+
const commits = logResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
1844
|
+
const fingerprint = buildChangeAuditFingerprint([
|
|
1845
|
+
repoRoot,
|
|
1846
|
+
baseRef,
|
|
1847
|
+
currentBranch.trim(),
|
|
1848
|
+
headRef.trim(),
|
|
1849
|
+
stat,
|
|
1850
|
+
files.join('\n'),
|
|
1851
|
+
commits.join('\n'),
|
|
1852
|
+
diff,
|
|
1853
|
+
]);
|
|
1854
|
+
return {
|
|
1855
|
+
available: true,
|
|
1856
|
+
workspaceRoot,
|
|
1857
|
+
repoRoot,
|
|
1858
|
+
baseRef,
|
|
1859
|
+
baseBranch: trimmedBase,
|
|
1860
|
+
currentBranch: hasRequestedHead ? requestedHead : (currentBranch.trim() || null),
|
|
1861
|
+
headRef: headRef.trim() || null,
|
|
1862
|
+
diffFingerprint: fingerprint,
|
|
1863
|
+
stat,
|
|
1864
|
+
files,
|
|
1865
|
+
skippedFiles: untrackedResult.skippedFiles.length > 0 ? untrackedResult.skippedFiles : undefined,
|
|
1866
|
+
hunks,
|
|
1867
|
+
commits,
|
|
1868
|
+
commitCount: commits.length,
|
|
1869
|
+
diff,
|
|
1870
|
+
truncated: statResult.truncated || nameResult.truncated || logResult.truncated || diffResult.truncated || untrackedResult.truncated,
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
async function getCommitDiffPayload(workspaceRoot, repoRoot, commit, signal) {
|
|
1874
|
+
const trimmedCommit = commit.trim();
|
|
1875
|
+
if (!isSafeGitRefName(trimmedCommit)) {
|
|
1876
|
+
return { available: false, workspaceRoot, repoRoot, baseBranch: trimmedCommit, error: 'Invalid commit ref' };
|
|
1877
|
+
}
|
|
1878
|
+
const [currentBranch, headRef, subject, statResult, nameResult, diffResult] = await Promise.all([
|
|
1879
|
+
execGit(['branch', '--show-current'], repoRoot, signal).catch(emptyOnNonAbortGitError),
|
|
1880
|
+
execGit(['rev-parse', '--short=12', trimmedCommit], repoRoot, signal).catch(emptyOnNonAbortGitError),
|
|
1881
|
+
execGit(['log', '-1', '--format=%s', trimmedCommit], repoRoot, signal).catch(emptyOnNonAbortGitError),
|
|
1882
|
+
execGitLimited(['show', '--stat', '--format=', trimmedCommit], repoRoot, MAX_BRANCH_DIFF_STAT_BYTES, false, signal),
|
|
1883
|
+
execGitLimited(['show', '--name-only', '--format=', trimmedCommit], repoRoot, MAX_BRANCH_DIFF_NAME_BYTES, false, signal),
|
|
1884
|
+
execGitLimited(['show', '--format=', trimmedCommit], repoRoot, MAX_BRANCH_DIFF_BYTES, false, signal),
|
|
1885
|
+
]);
|
|
1886
|
+
const diff = diffResult.stdout;
|
|
1887
|
+
const stat = statResult.stdout.trim();
|
|
1888
|
+
const files = Array.from(new Set(nameResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)));
|
|
1889
|
+
const hunks = parseBranchDiffHunks(diff).map((hunk) => ({ ...hunk, source: 'committed', commit: headRef.trim() || trimmedCommit }));
|
|
1890
|
+
const fingerprint = buildChangeAuditFingerprint([
|
|
1891
|
+
repoRoot,
|
|
1892
|
+
trimmedCommit,
|
|
1893
|
+
currentBranch.trim(),
|
|
1894
|
+
headRef.trim(),
|
|
1895
|
+
stat,
|
|
1896
|
+
files.join('\n'),
|
|
1897
|
+
subject.trim(),
|
|
1898
|
+
diff,
|
|
1899
|
+
]);
|
|
1900
|
+
return {
|
|
1901
|
+
available: true,
|
|
1902
|
+
workspaceRoot,
|
|
1903
|
+
repoRoot,
|
|
1904
|
+
baseRef: `${trimmedCommit}^`,
|
|
1905
|
+
baseBranch: trimmedCommit,
|
|
1906
|
+
currentBranch: currentBranch.trim() || null,
|
|
1907
|
+
headRef: headRef.trim() || trimmedCommit,
|
|
1908
|
+
diffFingerprint: fingerprint,
|
|
1909
|
+
stat,
|
|
1910
|
+
files,
|
|
1911
|
+
hunks,
|
|
1912
|
+
commits: [subject.trim()].filter(Boolean),
|
|
1913
|
+
commitCount: subject.trim() ? 1 : 0,
|
|
1914
|
+
diff,
|
|
1915
|
+
truncated: statResult.truncated || nameResult.truncated || diffResult.truncated,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
// In-memory cache for `git rev-parse --show-toplevel` results.
|
|
1919
|
+
// Key = requested cwd, Value = { root, expiresAt }. Invalidated quickly so
|
|
1920
|
+
// directory changes still propagate, but reused within the same UI burst
|
|
1921
|
+
// (open sidebar fires ~3 git fetches at once).
|
|
1922
|
+
const GIT_ROOT_CACHE_TTL_MS = 5_000;
|
|
1923
|
+
const gitRootCache = new Map();
|
|
1924
|
+
const gitBundleCache = new Map();
|
|
1925
|
+
const gitBundleBuildPromises = new Map();
|
|
1926
|
+
function getGitBundleCacheKey(gitRoot, includeNested) {
|
|
1927
|
+
return `${gitRoot}\u0000${includeNested ? 'nested' : 'single'}`;
|
|
1928
|
+
}
|
|
1929
|
+
function clearGitBundleCacheForRoot(root) {
|
|
1930
|
+
for (const key of gitBundleCache.keys()) {
|
|
1931
|
+
if (key.startsWith(`${root}\u0000`))
|
|
1932
|
+
gitBundleCache.delete(key);
|
|
1933
|
+
}
|
|
1934
|
+
for (const key of gitBundleBuildPromises.keys()) {
|
|
1935
|
+
if (key.startsWith(`${root}\u0000`))
|
|
1936
|
+
gitBundleBuildPromises.delete(key);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/** Find the top-level directory of the git repo containing `cwd`, or null. */
|
|
1940
|
+
async function findGitRoot(cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1941
|
+
const now = Date.now();
|
|
1942
|
+
const cached = gitRootCache.get(cwd);
|
|
1943
|
+
if (cached && cached.expiresAt > now) {
|
|
1944
|
+
return cached.root;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
const root = (await execGit(['rev-parse', '--show-toplevel'], cwd, undefined, timeoutMs)).trim() || null;
|
|
1948
|
+
gitRootCache.set(cwd, { root, expiresAt: now + GIT_ROOT_CACHE_TTL_MS });
|
|
1949
|
+
return root;
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
// Do NOT cache null — a transient failure (timeout, lock contention, I/O
|
|
1953
|
+
// delay) must not be mistaken for "not a git repository". On a real
|
|
1954
|
+
// non-git directory git rev-parse fails instantly (< 50 ms), so the
|
|
1955
|
+
// cost of retrying is negligible.
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
async function hasGitMetadata(candidate) {
|
|
1960
|
+
try {
|
|
1961
|
+
const stat = await fs.promises.lstat(path.join(candidate, '.git'));
|
|
1962
|
+
return stat.isDirectory() || stat.isFile();
|
|
1963
|
+
}
|
|
1964
|
+
catch {
|
|
1965
|
+
return false;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
async function getDirectoryTarget(candidate, entry) {
|
|
1969
|
+
if (entry.isDirectory())
|
|
1970
|
+
return candidate;
|
|
1971
|
+
if (!entry.isSymbolicLink())
|
|
1972
|
+
return null;
|
|
1973
|
+
try {
|
|
1974
|
+
const realPath = await fs.promises.realpath(candidate);
|
|
1975
|
+
const stat = await fs.promises.stat(realPath);
|
|
1976
|
+
return stat.isDirectory() ? realPath : null;
|
|
1977
|
+
}
|
|
1978
|
+
catch {
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
async function discoverNestedGitRoots(workspaceRoot, signal) {
|
|
1983
|
+
const repositories = [];
|
|
1984
|
+
const seen = new Set([workspaceRoot]);
|
|
1985
|
+
const deadline = Date.now() + NESTED_GIT_DISCOVERY_TIMEOUT_MS;
|
|
1986
|
+
let truncated = false;
|
|
1987
|
+
async function visit(dir) {
|
|
1988
|
+
if (signal)
|
|
1989
|
+
throwIfAborted(signal, 'git.bundle');
|
|
1990
|
+
if (truncated)
|
|
1991
|
+
return;
|
|
1992
|
+
if (Date.now() > deadline) {
|
|
1993
|
+
truncated = true;
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
let entries;
|
|
1997
|
+
try {
|
|
1998
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
1999
|
+
}
|
|
2000
|
+
catch {
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
const childDirectories = [];
|
|
2004
|
+
for (const entry of entries) {
|
|
2005
|
+
if (signal)
|
|
2006
|
+
throwIfAborted(signal, 'git.bundle');
|
|
2007
|
+
if (truncated)
|
|
2008
|
+
return;
|
|
2009
|
+
if (Date.now() > deadline) {
|
|
2010
|
+
truncated = true;
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
if (NESTED_GIT_DISCOVERY_IGNORED_NAMES.has(entry.name))
|
|
2014
|
+
continue;
|
|
2015
|
+
const candidate = path.join(dir, entry.name);
|
|
2016
|
+
const target = await getDirectoryTarget(candidate, entry);
|
|
2017
|
+
if (!target)
|
|
2018
|
+
continue;
|
|
2019
|
+
// A Git repository is a hard boundary: record it, then do not scan
|
|
2020
|
+
// deeper inside it. This catches containers like `repos/android` while
|
|
2021
|
+
// avoiding expensive nested scans through large project worktrees.
|
|
2022
|
+
if (await hasGitMetadata(candidate) || await hasGitMetadata(target)) {
|
|
2023
|
+
const root = await findGitRoot(target);
|
|
2024
|
+
if (root && root !== workspaceRoot && !seen.has(root)) {
|
|
2025
|
+
seen.add(root);
|
|
2026
|
+
repositories.push({ root, displayRoot: candidate });
|
|
2027
|
+
if (repositories.length >= MAX_NESTED_GIT_REPOS) {
|
|
2028
|
+
truncated = true;
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
if (!entry.isSymbolicLink())
|
|
2035
|
+
childDirectories.push({ entry, target });
|
|
2036
|
+
}
|
|
2037
|
+
for (const child of childDirectories) {
|
|
2038
|
+
if (signal)
|
|
2039
|
+
throwIfAborted(signal, 'git.bundle');
|
|
2040
|
+
if (truncated)
|
|
2041
|
+
return;
|
|
2042
|
+
await visit(child.target);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
if (signal)
|
|
2046
|
+
throwIfAborted(signal, 'git.bundle');
|
|
2047
|
+
await visit(workspaceRoot);
|
|
2048
|
+
if (signal)
|
|
2049
|
+
throwIfAborted(signal, 'git.bundle');
|
|
2050
|
+
repositories.sort((a, b) => a.displayRoot.localeCompare(b.displayRoot));
|
|
2051
|
+
return { repositories, truncated };
|
|
2052
|
+
}
|
|
2053
|
+
async function getCachedNestedGitRoots(workspaceRoot, options = {}) {
|
|
2054
|
+
if (options.signal)
|
|
2055
|
+
throwIfAborted(options.signal, 'git.bundle');
|
|
2056
|
+
const now = Date.now();
|
|
2057
|
+
const cached = nestedGitRootsCache.get(workspaceRoot);
|
|
2058
|
+
if (!options.refresh && cached) {
|
|
2059
|
+
if (cached.expiresAt > now)
|
|
2060
|
+
return cached.result;
|
|
2061
|
+
if (cached.promise)
|
|
2062
|
+
return cached.promise;
|
|
2063
|
+
}
|
|
2064
|
+
const promise = discoverNestedGitRoots(workspaceRoot, options.signal)
|
|
2065
|
+
.then((result) => {
|
|
2066
|
+
if (result.truncated && cached?.result.repositories.length) {
|
|
2067
|
+
return { ...cached.result, truncated: true };
|
|
2068
|
+
}
|
|
2069
|
+
nestedGitRootsCache.set(workspaceRoot, {
|
|
2070
|
+
result,
|
|
2071
|
+
expiresAt: Date.now() + NESTED_GIT_DISCOVERY_CACHE_TTL_MS,
|
|
2072
|
+
});
|
|
2073
|
+
return result;
|
|
2074
|
+
})
|
|
2075
|
+
.finally(() => {
|
|
2076
|
+
const current = nestedGitRootsCache.get(workspaceRoot);
|
|
2077
|
+
if (current?.promise === promise) {
|
|
2078
|
+
nestedGitRootsCache.set(workspaceRoot, {
|
|
2079
|
+
result: current.result,
|
|
2080
|
+
expiresAt: current.expiresAt,
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
});
|
|
2084
|
+
if (cached?.result) {
|
|
2085
|
+
nestedGitRootsCache.set(workspaceRoot, { ...cached, promise });
|
|
2086
|
+
}
|
|
2087
|
+
else {
|
|
2088
|
+
nestedGitRootsCache.set(workspaceRoot, {
|
|
2089
|
+
result: { repositories: [], truncated: false },
|
|
2090
|
+
expiresAt: 0,
|
|
2091
|
+
promise,
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
return promise;
|
|
2095
|
+
}
|
|
2096
|
+
// Directory listing
|
|
2097
|
+
router.get('/list', async (req, res) => {
|
|
2098
|
+
const requestId = ++fsIoRequestSeq;
|
|
2099
|
+
const startedAt = Date.now();
|
|
2100
|
+
const requestedPath = req.query.path;
|
|
2101
|
+
const action = getRequestAction(req, 'list_directory');
|
|
2102
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2103
|
+
const controller = new AbortController();
|
|
2104
|
+
const abortRequest = () => {
|
|
2105
|
+
if (!res.writableEnded)
|
|
2106
|
+
controller.abort(new SupersededRequestError('fs.list'));
|
|
2107
|
+
};
|
|
2108
|
+
req.on('aborted', abortRequest);
|
|
2109
|
+
res.on('close', abortRequest);
|
|
2110
|
+
registerIoSlot({ requestId, op: 'fs.list', action, slotId: requestSlotId, controller, path: requestedPath });
|
|
2111
|
+
try {
|
|
2112
|
+
if (!requestedPath) {
|
|
2113
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const result = await withTimeout((async () => {
|
|
2117
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2118
|
+
throwIfAborted(controller.signal, 'fs.list');
|
|
2119
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
2120
|
+
throwIfAborted(controller.signal, 'fs.list');
|
|
2121
|
+
if (!stat.isDirectory()) {
|
|
2122
|
+
throw new Error('Path is not a directory');
|
|
2123
|
+
}
|
|
2124
|
+
const showHidden = req.query.showHidden === 'true';
|
|
2125
|
+
const allEntries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
2126
|
+
throwIfAborted(controller.signal, 'fs.list');
|
|
2127
|
+
const visibleDirents = allEntries.filter(dirent => showHidden || !dirent.name.startsWith('.'));
|
|
2128
|
+
const returnedDirents = visibleDirents
|
|
2129
|
+
.sort(compareDirents)
|
|
2130
|
+
.slice(0, MAX_DIRECTORY_ENTRIES);
|
|
2131
|
+
const entries = await Promise.all(returnedDirents.map((dirent) => toDirectoryEntry(resolvedPath, dirent)));
|
|
2132
|
+
throwIfAborted(controller.signal, 'fs.list');
|
|
2133
|
+
return { resolvedPath, entries, total: visibleDirents.length };
|
|
2134
|
+
})(), FS_ROUTE_TIMEOUT_MS, 'Directory listing took too long. The folder may be on a slow disk or network mount.', 'FS_LIST_TIMEOUT');
|
|
2135
|
+
res.setHeader('X-Termdock-FS-List-Duration-Ms', String(Date.now() - startedAt));
|
|
2136
|
+
res.setHeader('X-Termdock-FS-List-Total', String(result.total));
|
|
2137
|
+
res.setHeader('X-Termdock-FS-List-Returned', String(result.entries.length));
|
|
2138
|
+
logFsIo({ id: requestId, action, op: 'fs.list', startedAt, status: 'ok', path: result.resolvedPath, count: result.entries.length, total: result.total, truncated: result.total > result.entries.length, extra: { requestSlotId } });
|
|
2139
|
+
res.json({ path: result.resolvedPath, entries: result.entries, truncated: result.total > result.entries.length, total: result.total });
|
|
2140
|
+
}
|
|
2141
|
+
catch (error) {
|
|
2142
|
+
const payload = getErrorPayload(error);
|
|
2143
|
+
logFsIo({ id: requestId, action, op: 'fs.list', startedAt, status: 'error', path: requestedPath, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
2144
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 403).json(payload);
|
|
2145
|
+
}
|
|
2146
|
+
finally {
|
|
2147
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
// Stream file-system changes for the active file explorer roots. This is not a
|
|
2151
|
+
// deep directory listing; it uses the OS watcher and sends small batched events
|
|
2152
|
+
// so the client can patch only directories it has already loaded.
|
|
2153
|
+
router.get('/watch', async (req, res) => {
|
|
2154
|
+
const rootsParam = req.query.roots;
|
|
2155
|
+
const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
|
|
2156
|
+
.filter((root) => typeof root === 'string');
|
|
2157
|
+
const roots = [];
|
|
2158
|
+
for (const rawRoot of rawRoots) {
|
|
2159
|
+
if (!rawRoot)
|
|
2160
|
+
continue;
|
|
2161
|
+
try {
|
|
2162
|
+
const resolved = await pathValidator.validatePathAsync(rawRoot);
|
|
2163
|
+
const stat = await fs.promises.stat(resolved);
|
|
2164
|
+
if (stat.isDirectory() && !roots.includes(resolved))
|
|
2165
|
+
roots.push(resolved);
|
|
2166
|
+
}
|
|
2167
|
+
catch {
|
|
2168
|
+
// Ignore invalid watch roots; the visible tree will still work via manual list requests.
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
if (roots.length === 0) {
|
|
2172
|
+
res.status(400).json({ error: 'No valid roots to watch' });
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
2176
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2177
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2178
|
+
let closed = false;
|
|
2179
|
+
let pending = [];
|
|
2180
|
+
let flushTimer = null;
|
|
2181
|
+
const subscriptions = [];
|
|
2182
|
+
const writeEvent = (type, payload) => {
|
|
2183
|
+
if (closed || res.destroyed)
|
|
2184
|
+
return;
|
|
2185
|
+
res.write(`${JSON.stringify({ type, ...payload })}\n`);
|
|
2186
|
+
};
|
|
2187
|
+
const flush = () => {
|
|
2188
|
+
flushTimer = null;
|
|
2189
|
+
if (pending.length === 0)
|
|
2190
|
+
return;
|
|
2191
|
+
const events = pending;
|
|
2192
|
+
pending = [];
|
|
2193
|
+
writeEvent('events', { events });
|
|
2194
|
+
};
|
|
2195
|
+
const enqueue = (event) => {
|
|
2196
|
+
if (closed)
|
|
2197
|
+
return;
|
|
2198
|
+
if (pending.length >= WATCH_EVENT_STORM_LIMIT) {
|
|
2199
|
+
pending = roots.map((rootPath) => ({ type: 'rescan-required', path: rootPath, reason: 'event-storm' }));
|
|
2200
|
+
}
|
|
2201
|
+
else {
|
|
2202
|
+
pending.push(event);
|
|
2203
|
+
}
|
|
2204
|
+
if (!flushTimer) {
|
|
2205
|
+
flushTimer = setTimeout(flush, WATCH_BATCH_MS);
|
|
2206
|
+
flushTimer.unref?.();
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
writeEvent('ready', { roots });
|
|
2210
|
+
for (const rootPath of roots) {
|
|
2211
|
+
try {
|
|
2212
|
+
const subscription = await watcher.subscribe(rootPath, (error, events) => {
|
|
2213
|
+
if (closed)
|
|
2214
|
+
return;
|
|
2215
|
+
if (error) {
|
|
2216
|
+
enqueue({ type: 'rescan-required', path: rootPath, reason: error.message || 'watch-error' });
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
for (const event of events) {
|
|
2220
|
+
const changedPath = path.resolve(event.path);
|
|
2221
|
+
if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
|
|
2222
|
+
continue;
|
|
2223
|
+
if (event.type === 'delete') {
|
|
2224
|
+
enqueue({ type: 'deleted', path: changedPath });
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
fs.promises.lstat(changedPath)
|
|
2228
|
+
.then((stat) => {
|
|
2229
|
+
enqueue({ type: event.type === 'create' ? 'created' : 'updated', path: changedPath, entry: toFileEntry(changedPath, stat) });
|
|
2230
|
+
})
|
|
2231
|
+
.catch(() => {
|
|
2232
|
+
enqueue({ type: 'deleted', path: changedPath });
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
}, {
|
|
2236
|
+
ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
|
|
2237
|
+
});
|
|
2238
|
+
subscriptions.push(subscription);
|
|
2239
|
+
}
|
|
2240
|
+
catch (error) {
|
|
2241
|
+
enqueue({ type: 'rescan-required', path: rootPath, reason: error instanceof Error ? error.message : 'watch-unavailable' });
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
req.on('close', () => {
|
|
2245
|
+
closed = true;
|
|
2246
|
+
if (flushTimer)
|
|
2247
|
+
clearTimeout(flushTimer);
|
|
2248
|
+
for (const subscription of subscriptions) {
|
|
2249
|
+
void subscription.unsubscribe().catch(() => undefined);
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
router.get('/cancel-slot', (req, res) => {
|
|
2254
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2255
|
+
const action = getRequestAction(req, 'cancel_io_slot');
|
|
2256
|
+
if (!requestSlotId) {
|
|
2257
|
+
res.status(400).json({ ok: false, error: 'Missing requestSlotId' });
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
const diff = activeDiffSlots.get(requestSlotId);
|
|
2261
|
+
const io = activeIoSlots.get(requestSlotId);
|
|
2262
|
+
if (diff) {
|
|
2263
|
+
diff.controller.abort(new SupersededRequestError('git.diff'));
|
|
2264
|
+
activeDiffSlots.delete(requestSlotId);
|
|
2265
|
+
}
|
|
2266
|
+
if (io) {
|
|
2267
|
+
io.controller.abort(new SupersededRequestError(io.op));
|
|
2268
|
+
activeIoSlots.delete(requestSlotId);
|
|
2269
|
+
}
|
|
2270
|
+
logFsIoEvent({
|
|
2271
|
+
action,
|
|
2272
|
+
op: 'io.cancel-slot',
|
|
2273
|
+
event: diff || io ? 'slot-cancelled' : 'slot-not-found',
|
|
2274
|
+
extra: {
|
|
2275
|
+
requestSlotId,
|
|
2276
|
+
diffRequestId: diff?.requestId,
|
|
2277
|
+
ioRequestId: io?.requestId,
|
|
2278
|
+
ioOp: io?.op,
|
|
2279
|
+
},
|
|
2280
|
+
});
|
|
2281
|
+
res.json({ ok: true, cancelled: Boolean(diff || io) });
|
|
2282
|
+
});
|
|
2283
|
+
// Fast recursive file search for the right sidebar.
|
|
2284
|
+
// Prefer ripgrep because it respects .gitignore, skips ignored/build folders,
|
|
2285
|
+
// and is dramatically faster than recursively calling readdir from the browser.
|
|
2286
|
+
router.get('/search', async (req, res) => {
|
|
2287
|
+
const requestId = ++fsIoRequestSeq;
|
|
2288
|
+
const startedAt = Date.now();
|
|
2289
|
+
const controller = new AbortController();
|
|
2290
|
+
const requestedPath = req.query.path;
|
|
2291
|
+
const action = getRequestAction(req, 'search_files');
|
|
2292
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2293
|
+
let logged = false;
|
|
2294
|
+
const logSearch = (status, entry = {}) => {
|
|
2295
|
+
if (logged)
|
|
2296
|
+
return;
|
|
2297
|
+
logged = true;
|
|
2298
|
+
logFsIo({
|
|
2299
|
+
id: requestId,
|
|
2300
|
+
action,
|
|
2301
|
+
op: 'fs.search',
|
|
2302
|
+
startedAt,
|
|
2303
|
+
status,
|
|
2304
|
+
path: requestedPath,
|
|
2305
|
+
...entry,
|
|
2306
|
+
extra: { ...(entry.extra ?? {}), requestSlotId },
|
|
2307
|
+
});
|
|
2308
|
+
};
|
|
2309
|
+
req.on('close', () => controller.abort(new SupersededRequestError('fs.search')));
|
|
2310
|
+
registerIoSlot({ requestId, op: 'fs.search', action, slotId: requestSlotId, controller, path: requestedPath });
|
|
2311
|
+
try {
|
|
2312
|
+
if (!requestedPath) {
|
|
2313
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const query = normalizeSearchQuery(req.query.query);
|
|
2317
|
+
if (!query) {
|
|
2318
|
+
logSearch('ok', { count: 0, total: 0, extra: { mode: 'empty' } });
|
|
2319
|
+
res.json({ path: requestedPath, query: '', entries: [], truncated: false, total: 0, engine: 'rg' });
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2323
|
+
throwIfAborted(controller.signal, 'fs.search');
|
|
2324
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
2325
|
+
throwIfAborted(controller.signal, 'fs.search');
|
|
2326
|
+
if (!stat.isDirectory()) {
|
|
2327
|
+
res.status(400).json({ error: 'Path is not a directory' });
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const showHidden = req.query.showHidden === 'true';
|
|
2331
|
+
const mode = req.query.mode === 'content' ? 'content' : 'name';
|
|
2332
|
+
const queryLower = query.toLowerCase();
|
|
2333
|
+
if (mode === 'content') {
|
|
2334
|
+
if (req.query.stream !== 'true') {
|
|
2335
|
+
res.status(400).json({ error: 'Content search requires streaming', code: 'CONTENT_SEARCH_STREAM_ONLY' });
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
2339
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2340
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2341
|
+
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', mode: 'content', limited: false });
|
|
2342
|
+
try {
|
|
2343
|
+
const result = await streamContentSearchWithRipgrep(resolvedPath, query, showHidden, controller.signal, res);
|
|
2344
|
+
if (!controller.signal.aborted && !res.destroyed) {
|
|
2345
|
+
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'rg', mode: 'content' });
|
|
2346
|
+
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'content', engine: 'rg' } });
|
|
2347
|
+
res.end();
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
catch (error) {
|
|
2351
|
+
if (controller.signal.aborted || res.destroyed) {
|
|
2352
|
+
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2353
|
+
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'content', engine: 'rg' } });
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
const message = error instanceof Error ? error.message : 'Content search failed';
|
|
2357
|
+
logSearch('error', { code: 'CONTENT_SEARCH_UNAVAILABLE', error: message, extra: { mode: 'content', engine: 'rg' } });
|
|
2358
|
+
writeSearchEvent(res, 'error', { message, code: 'CONTENT_SEARCH_UNAVAILABLE' });
|
|
2359
|
+
res.end();
|
|
2360
|
+
}
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
if (req.query.stream === 'true') {
|
|
2364
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
2365
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2366
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2367
|
+
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', limited: false });
|
|
2368
|
+
try {
|
|
2369
|
+
const total = await streamSearchWithRipgrep(resolvedPath, queryLower, showHidden, controller.signal, res);
|
|
2370
|
+
if (!controller.signal.aborted && !res.destroyed) {
|
|
2371
|
+
writeSearchEvent(res, 'done', { total, truncated: false, limited: false, engine: 'rg' });
|
|
2372
|
+
logSearch('ok', { count: total, truncated: false, extra: { mode: 'name', engine: 'rg' } });
|
|
2373
|
+
res.end();
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
catch (error) {
|
|
2377
|
+
if (controller.signal.aborted || res.destroyed) {
|
|
2378
|
+
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2379
|
+
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'fallback', limited: false });
|
|
2383
|
+
const result = await streamSearchWithFallback(resolvedPath, queryLower, showHidden, controller.signal, res);
|
|
2384
|
+
if (!controller.signal.aborted && !res.destroyed) {
|
|
2385
|
+
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'fallback' });
|
|
2386
|
+
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'name', engine: 'fallback' } });
|
|
2387
|
+
res.end();
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
try {
|
|
2393
|
+
const result = await searchWithRipgrep(resolvedPath, queryLower, showHidden, controller.signal);
|
|
2394
|
+
logSearch('ok', { count: result.entries.length, total: result.total, truncated: result.truncated, extra: { mode: 'name', engine: result.engine } });
|
|
2395
|
+
res.json(result);
|
|
2396
|
+
}
|
|
2397
|
+
catch (error) {
|
|
2398
|
+
if (controller.signal.aborted) {
|
|
2399
|
+
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2400
|
+
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
// Graceful fallback for machines without `rg` installed. It is bounded so
|
|
2404
|
+
// searching an enormous home directory cannot monopolize the server.
|
|
2405
|
+
const result = await searchWithFallback(resolvedPath, queryLower, showHidden, controller.signal);
|
|
2406
|
+
logSearch('ok', { count: result.entries.length, total: result.total, truncated: result.truncated, extra: { mode: 'name', engine: result.engine } });
|
|
2407
|
+
res.json(result);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
catch (error) {
|
|
2411
|
+
if (controller.signal.aborted) {
|
|
2412
|
+
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2413
|
+
logSearch('error', { code: payload.code, error: payload.error });
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
2417
|
+
logSearch('error', { error: message });
|
|
2418
|
+
res.status(403).json({ error: message });
|
|
2419
|
+
}
|
|
2420
|
+
finally {
|
|
2421
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
2422
|
+
}
|
|
2423
|
+
});
|
|
2424
|
+
// Read file content
|
|
2425
|
+
router.get('/read', async (req, res) => {
|
|
2426
|
+
const requestId = ++fsIoRequestSeq;
|
|
2427
|
+
const startedAt = Date.now();
|
|
2428
|
+
const requestedPath = req.query.path;
|
|
2429
|
+
const action = getRequestAction(req, 'view_file');
|
|
2430
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2431
|
+
const controller = new AbortController();
|
|
2432
|
+
const abortRequest = () => {
|
|
2433
|
+
if (!res.writableEnded)
|
|
2434
|
+
controller.abort(new SupersededRequestError('fs.read'));
|
|
2435
|
+
};
|
|
2436
|
+
req.on('aborted', abortRequest);
|
|
2437
|
+
res.on('close', abortRequest);
|
|
2438
|
+
registerIoSlot({ requestId, op: 'fs.read', action, slotId: requestSlotId, controller, path: requestedPath });
|
|
2439
|
+
try {
|
|
2440
|
+
if (!requestedPath) {
|
|
2441
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
const result = await withTimeout((async () => {
|
|
2445
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2446
|
+
throwIfAborted(controller.signal, 'fs.read');
|
|
2447
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
2448
|
+
throwIfAborted(controller.signal, 'fs.read');
|
|
2449
|
+
if (stat.isDirectory()) {
|
|
2450
|
+
throw new Error('Path is a directory, not a file');
|
|
2451
|
+
}
|
|
2452
|
+
const bytesToRead = Math.min(stat.size, MAX_FILE_SIZE);
|
|
2453
|
+
const truncated = stat.size > bytesToRead;
|
|
2454
|
+
const buffer = await readBytesPrefix(resolvedPath, bytesToRead);
|
|
2455
|
+
// NUL-byte heuristic on the first 8KB: binary files (zip/elf/class/...)
|
|
2456
|
+
// contain a 0x00 byte very early, text files never do. This lets the
|
|
2457
|
+
// frontend show a "cannot preview" state instead of dumping garbled bytes.
|
|
2458
|
+
const binary = buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0);
|
|
2459
|
+
throwIfAborted(controller.signal, 'fs.read');
|
|
2460
|
+
return { resolvedPath, stat, buffer, binary, truncated };
|
|
2461
|
+
})(), FS_ROUTE_TIMEOUT_MS, 'File preview took too long. The file may be on slow storage or currently blocked by another process.', 'FS_READ_TIMEOUT');
|
|
2462
|
+
logFsIo({ id: requestId, action, op: 'fs.read', startedAt, status: 'ok', path: result.resolvedPath, bytes: Math.min(result.stat.size, MAX_FILE_SIZE), total: result.stat.size, truncated: result.truncated, extra: { requestSlotId } });
|
|
2463
|
+
res.json({
|
|
2464
|
+
path: result.resolvedPath,
|
|
2465
|
+
content: result.binary ? '' : result.buffer.toString('utf-8'),
|
|
2466
|
+
size: result.stat.size,
|
|
2467
|
+
modified: result.stat.mtime.toISOString(),
|
|
2468
|
+
truncated: result.binary ? false : result.truncated,
|
|
2469
|
+
binary: result.binary,
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
catch (error) {
|
|
2473
|
+
const payload = getErrorPayload(error);
|
|
2474
|
+
logFsIo({ id: requestId, action, op: 'fs.read', startedAt, status: 'error', path: requestedPath, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
2475
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 403).json(payload);
|
|
2476
|
+
}
|
|
2477
|
+
finally {
|
|
2478
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
router.get('/git-blob', async (req, res) => {
|
|
2482
|
+
const requestId = ++fsIoRequestSeq;
|
|
2483
|
+
const startedAt = Date.now();
|
|
2484
|
+
const requestedPath = typeof req.query.path === 'string' ? req.query.path : undefined;
|
|
2485
|
+
const cwd = typeof req.query.cwd === 'string' ? req.query.cwd : undefined;
|
|
2486
|
+
const ref = typeof req.query.ref === 'string' ? req.query.ref : 'HEAD';
|
|
2487
|
+
const source = req.query.source === 'index' ? 'index' : 'ref';
|
|
2488
|
+
const controller = new AbortController();
|
|
2489
|
+
const abortRequest = () => {
|
|
2490
|
+
if (!res.writableEnded)
|
|
2491
|
+
controller.abort(new SupersededRequestError('git.blob'));
|
|
2492
|
+
};
|
|
2493
|
+
req.on('aborted', abortRequest);
|
|
2494
|
+
res.on('close', abortRequest);
|
|
2495
|
+
try {
|
|
2496
|
+
if (!requestedPath) {
|
|
2497
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
if (source !== 'index' && !isSafeGitRefName(ref)) {
|
|
2501
|
+
res.status(400).json({ error: 'Invalid git ref' });
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
const resolvedCwd = cwd ? await pathValidator.validatePathAsync(cwd) : process.cwd();
|
|
2505
|
+
const refresh = req.query.refresh === 'true';
|
|
2506
|
+
const gitRoot = await findGitRoot(resolvedCwd, refresh ? null : GIT_TIMEOUT_MS);
|
|
2507
|
+
if (!gitRoot) {
|
|
2508
|
+
res.status(400).json({ error: 'Not a git repository' });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const pathspec = await toGitPathspec(gitRoot, requestedPath);
|
|
2512
|
+
const objectSpec = source === 'index' ? `:${pathspec}` : `${ref}:${pathspec}`;
|
|
2513
|
+
const result = await execGitLimited(['show', objectSpec], gitRoot, MAX_FILE_SIZE, false, controller.signal);
|
|
2514
|
+
logFsIo({ id: requestId, action: 'git_blob', op: 'git.blob', startedAt, status: 'ok', path: requestedPath, cwd, repoRoot: gitRoot, bytes: Buffer.byteLength(result.stdout), truncated: result.truncated });
|
|
2515
|
+
res.json({
|
|
2516
|
+
path: requestedPath,
|
|
2517
|
+
ref: source === 'index' ? ':index' : ref,
|
|
2518
|
+
source,
|
|
2519
|
+
content: result.truncated ? '' : result.stdout,
|
|
2520
|
+
truncated: result.truncated || undefined,
|
|
2521
|
+
size: Buffer.byteLength(result.stdout),
|
|
2522
|
+
maxBytes: result.truncated ? MAX_FILE_SIZE : undefined,
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
catch (error) {
|
|
2526
|
+
const payload = getErrorPayload(error);
|
|
2527
|
+
logFsIo({ id: requestId, action: 'git_blob', op: 'git.blob', startedAt, status: 'error', path: requestedPath, cwd, code: payload.code, error: payload.error });
|
|
2528
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ path: requestedPath ?? null, content: '', ...payload });
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
// Stream supported image files for the right sidebar preview.
|
|
2532
|
+
router.get('/blob', async (req, res) => {
|
|
2533
|
+
const requestId = ++fsIoRequestSeq;
|
|
2534
|
+
const startedAt = Date.now();
|
|
2535
|
+
const requestedPath = req.query.path;
|
|
2536
|
+
const action = getRequestAction(req, 'view_file');
|
|
2537
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2538
|
+
const controller = new AbortController();
|
|
2539
|
+
const abortRequest = () => {
|
|
2540
|
+
if (!res.writableEnded)
|
|
2541
|
+
controller.abort(new SupersededRequestError('fs.blob'));
|
|
2542
|
+
};
|
|
2543
|
+
req.on('aborted', abortRequest);
|
|
2544
|
+
res.on('close', abortRequest);
|
|
2545
|
+
registerIoSlot({ requestId, op: 'fs.blob', action, slotId: requestSlotId, controller, path: requestedPath });
|
|
2546
|
+
let logged = false;
|
|
2547
|
+
const logOnce = (status, entry = {}) => {
|
|
2548
|
+
if (logged)
|
|
2549
|
+
return;
|
|
2550
|
+
logged = true;
|
|
2551
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
2552
|
+
logFsIo({
|
|
2553
|
+
action,
|
|
2554
|
+
op: 'fs.blob',
|
|
2555
|
+
id: requestId,
|
|
2556
|
+
startedAt,
|
|
2557
|
+
status,
|
|
2558
|
+
path: requestedPath,
|
|
2559
|
+
...entry,
|
|
2560
|
+
extra: { ...(entry.extra ?? {}), requestSlotId },
|
|
2561
|
+
});
|
|
2562
|
+
};
|
|
2563
|
+
try {
|
|
2564
|
+
if (!requestedPath) {
|
|
2565
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
const { resolvedPath, stat, mimeType } = await withTimeout((async () => {
|
|
2569
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2570
|
+
throwIfAborted(controller.signal, 'fs.blob');
|
|
2571
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
2572
|
+
throwIfAborted(controller.signal, 'fs.blob');
|
|
2573
|
+
if (!stat.isFile()) {
|
|
2574
|
+
throw new Error('Path is not a file');
|
|
2575
|
+
}
|
|
2576
|
+
const mimeType = getImageMimeType(resolvedPath);
|
|
2577
|
+
if (!mimeType) {
|
|
2578
|
+
const error = new Error('Unsupported image type');
|
|
2579
|
+
error.status = 415;
|
|
2580
|
+
throw error;
|
|
2581
|
+
}
|
|
2582
|
+
if (stat.size > MAX_IMAGE_PREVIEW_SIZE) {
|
|
2583
|
+
const error = new Error('Image is too large to preview');
|
|
2584
|
+
error.code = 'IMAGE_TOO_LARGE';
|
|
2585
|
+
error.status = 413;
|
|
2586
|
+
throw error;
|
|
2587
|
+
}
|
|
2588
|
+
return { resolvedPath, stat, mimeType };
|
|
2589
|
+
})(), FS_ROUTE_TIMEOUT_MS, 'Image preview took too long. The file may be on slow storage or currently blocked by another process.', 'FS_BLOB_TIMEOUT');
|
|
2590
|
+
res.setHeader('Content-Type', mimeType);
|
|
2591
|
+
res.setHeader('Content-Length', stat.size.toString());
|
|
2592
|
+
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
2593
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2594
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
2595
|
+
res.setHeader('Content-Disposition', buildContentDisposition('inline', path.basename(resolvedPath)));
|
|
2596
|
+
const stream = fs.createReadStream(resolvedPath);
|
|
2597
|
+
controller.signal.addEventListener('abort', () => stream.destroy(controller.signal.reason instanceof Error ? controller.signal.reason : undefined), { once: true });
|
|
2598
|
+
res.on('finish', () => logOnce('ok', { path: resolvedPath, bytes: stat.size, extra: { mimeType } }));
|
|
2599
|
+
res.on('close', () => {
|
|
2600
|
+
if (!res.writableEnded)
|
|
2601
|
+
logOnce('error', { path: resolvedPath, bytes: stat.size, code: 'CLIENT_CLOSED', error: 'Client closed image preview request' });
|
|
2602
|
+
});
|
|
2603
|
+
stream.on('error', (error) => {
|
|
2604
|
+
logOnce('error', { path: resolvedPath, code: 'FS_BLOB_STREAM_ERROR', error: error instanceof Error ? error.message : 'Failed to read image' });
|
|
2605
|
+
if (!res.headersSent) {
|
|
2606
|
+
const message = error instanceof Error ? error.message : 'Failed to read image';
|
|
2607
|
+
res.status(500).json({ error: message });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
res.destroy(error instanceof Error ? error : undefined);
|
|
2611
|
+
});
|
|
2612
|
+
stream.pipe(res);
|
|
2613
|
+
}
|
|
2614
|
+
catch (error) {
|
|
2615
|
+
const payload = getErrorPayload(error);
|
|
2616
|
+
const status = error instanceof OperationTimeoutError
|
|
2617
|
+
? 504
|
|
2618
|
+
: typeof error?.status === 'number'
|
|
2619
|
+
? error.status
|
|
2620
|
+
: 403;
|
|
2621
|
+
const code = payload.code ?? (typeof error?.code === 'string' ? error.code : undefined);
|
|
2622
|
+
logOnce('error', { code, error: payload.error });
|
|
2623
|
+
res.status(status).json({ ...payload, code });
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2626
|
+
// Download any file as an attachment. Works in both PWA and normal browser
|
|
2627
|
+
// contexts — the frontend fetches this as a blob and either pipes it through
|
|
2628
|
+
// the File System Access API (showSaveFilePicker, desktop PWA/Chromium) or
|
|
2629
|
+
// falls back to an <a download> blob URL.
|
|
2630
|
+
router.get('/download', async (req, res) => {
|
|
2631
|
+
try {
|
|
2632
|
+
const requestedPath = req.query.path;
|
|
2633
|
+
if (!requestedPath) {
|
|
2634
|
+
res.status(400).json({ error: 'Missing path parameter' });
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2638
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
2639
|
+
if (!stat.isFile()) {
|
|
2640
|
+
res.status(400).json({ error: 'Path is not a file' });
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
if (stat.size > MAX_DOWNLOAD_SIZE) {
|
|
2644
|
+
res.status(413).json({
|
|
2645
|
+
error: 'File is too large to download',
|
|
2646
|
+
code: 'FILE_TOO_LARGE',
|
|
2647
|
+
size: stat.size,
|
|
2648
|
+
maxSize: MAX_DOWNLOAD_SIZE,
|
|
2649
|
+
});
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
2653
|
+
res.setHeader('Content-Length', stat.size.toString());
|
|
2654
|
+
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
2655
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
2656
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
2657
|
+
res.setHeader('Content-Disposition', buildContentDisposition('attachment', path.basename(resolvedPath)));
|
|
2658
|
+
const stream = fs.createReadStream(resolvedPath);
|
|
2659
|
+
stream.on('error', (error) => {
|
|
2660
|
+
if (!res.headersSent) {
|
|
2661
|
+
const message = error instanceof Error ? error.message : 'Failed to download file';
|
|
2662
|
+
res.status(500).json({ error: message });
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
res.destroy(error instanceof Error ? error : undefined);
|
|
2666
|
+
});
|
|
2667
|
+
stream.pipe(res);
|
|
2668
|
+
}
|
|
2669
|
+
catch (error) {
|
|
2670
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
2671
|
+
res.status(403).json({ error: message });
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
// Git diff for a file or the entire repo
|
|
2675
|
+
router.get('/diff', async (req, res) => {
|
|
2676
|
+
const requestId = ++fsIoRequestSeq;
|
|
2677
|
+
const inflight = updateInflight('git.diff', 1);
|
|
2678
|
+
const startedAt = Date.now();
|
|
2679
|
+
const requestedPath = req.query.path;
|
|
2680
|
+
const cwd = req.query.cwd;
|
|
2681
|
+
const traceId = typeof req.query.traceId === 'string' ? req.query.traceId : undefined;
|
|
2682
|
+
const interactionId = typeof req.query.interactionId === 'string' ? req.query.interactionId : undefined;
|
|
2683
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2684
|
+
const action = getRequestAction(req, requestedPath ? 'view_diff' : 'view_all_changes');
|
|
2685
|
+
const diffOptions = {
|
|
2686
|
+
algorithm: getGitDiffAlgorithm(req.query.algorithm),
|
|
2687
|
+
whitespace: getGitDiffWhitespaceMode(req.query.whitespace),
|
|
2688
|
+
};
|
|
2689
|
+
const diffOptionArgs = buildGitDiffOptionArgs(diffOptions);
|
|
2690
|
+
const controller = new AbortController();
|
|
2691
|
+
let requestClosed = false;
|
|
2692
|
+
const abortRequest = (event) => {
|
|
2693
|
+
if (requestClosed)
|
|
2694
|
+
return;
|
|
2695
|
+
requestClosed = true;
|
|
2696
|
+
logFsIoEvent({ id: requestId, action, op: 'git.diff', event, path: requestedPath, cwd, repoRoot: gitCwdForLog, requestClosed: true, extra: { traceId } });
|
|
2697
|
+
controller.abort();
|
|
2698
|
+
};
|
|
2699
|
+
req.on('aborted', () => abortRequest('request-aborted'));
|
|
2700
|
+
res.on('close', () => {
|
|
2701
|
+
if (!res.writableEnded)
|
|
2702
|
+
abortRequest('response-close-before-end');
|
|
2703
|
+
});
|
|
2704
|
+
const isConcreteFileDiff = Boolean(requestedPath);
|
|
2705
|
+
const diffTimeoutMs = isConcreteFileDiff ? GIT_FILE_DIFF_ROUTE_TIMEOUT_MS : GIT_ROUTE_TIMEOUT_MS;
|
|
2706
|
+
const diffTimeoutMessage = isConcreteFileDiff
|
|
2707
|
+
? 'Git diff is still running for this file. It may be blocked by repository IO or another Git process.'
|
|
2708
|
+
: 'Git diff took too long. The repository may be busy, on slow storage, or locked by another Git process.';
|
|
2709
|
+
let gitCwdForLog = null;
|
|
2710
|
+
if (requestSlotId && action === 'view_diff') {
|
|
2711
|
+
const previous = activeDiffSlots.get(requestSlotId);
|
|
2712
|
+
if (previous && previous.requestId !== requestId) {
|
|
2713
|
+
previous.controller.abort(new GitCommandAbortError('superseded by newer diff request'));
|
|
2714
|
+
logFsIoEvent({ id: requestId, action, op: 'git.diff', event: 'slot-cancel-previous', path: requestedPath, cwd, extra: { traceId, interactionId, requestSlotId, previousRequestId: previous.requestId } });
|
|
2715
|
+
writeDiffTraceLog({
|
|
2716
|
+
source: 'server.git-diff',
|
|
2717
|
+
event: 'slot-cancel-previous',
|
|
2718
|
+
requestId,
|
|
2719
|
+
action,
|
|
2720
|
+
traceId,
|
|
2721
|
+
interactionId,
|
|
2722
|
+
requestSlotId,
|
|
2723
|
+
previousRequestId: previous.requestId,
|
|
2724
|
+
filePath: requestedPath,
|
|
2725
|
+
cwd,
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
activeDiffSlots.set(requestSlotId, { controller, requestId });
|
|
2729
|
+
}
|
|
2730
|
+
logFsIoEvent({ id: requestId, action, op: 'git.diff', event: 'request-start', path: requestedPath, cwd, extra: { inflight, traceId, interactionId, requestSlotId, ...diffOptions } });
|
|
2731
|
+
writeDiffTraceLog({
|
|
2732
|
+
source: 'server.git-diff',
|
|
2733
|
+
event: 'request-start',
|
|
2734
|
+
requestId,
|
|
2735
|
+
action,
|
|
2736
|
+
traceId,
|
|
2737
|
+
interactionId,
|
|
2738
|
+
requestSlotId,
|
|
2739
|
+
filePath: requestedPath,
|
|
2740
|
+
cwd,
|
|
2741
|
+
inflight,
|
|
2742
|
+
diffOptions,
|
|
2743
|
+
});
|
|
2744
|
+
try {
|
|
2745
|
+
const result = await withTimeout((async () => {
|
|
2746
|
+
const cached = req.query.cached === 'true';
|
|
2747
|
+
// Determine the git working directory
|
|
2748
|
+
let gitCwd;
|
|
2749
|
+
if (requestedPath && path.isAbsolute(requestedPath)) {
|
|
2750
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
2751
|
+
const stat = await fs.promises.stat(resolvedPath).catch(() => null);
|
|
2752
|
+
gitCwd = await findGitRoot(stat?.isDirectory() ? resolvedPath : path.dirname(resolvedPath));
|
|
2753
|
+
}
|
|
2754
|
+
else if (cwd) {
|
|
2755
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
2756
|
+
gitCwd = await findGitRoot(resolvedCwd);
|
|
2757
|
+
}
|
|
2758
|
+
else {
|
|
2759
|
+
gitCwd = null;
|
|
2760
|
+
}
|
|
2761
|
+
gitCwdForLog = gitCwd;
|
|
2762
|
+
logFsIoEvent({ id: requestId, action, op: 'git.diff', event: 'git-root-resolved', path: requestedPath, cwd, repoRoot: gitCwd, requestClosed, extra: { traceId, interactionId } });
|
|
2763
|
+
writeDiffTraceLog({
|
|
2764
|
+
source: 'server.git-diff',
|
|
2765
|
+
event: 'git-root-resolved',
|
|
2766
|
+
requestId,
|
|
2767
|
+
action,
|
|
2768
|
+
traceId,
|
|
2769
|
+
interactionId,
|
|
2770
|
+
requestSlotId,
|
|
2771
|
+
filePath: requestedPath,
|
|
2772
|
+
cwd,
|
|
2773
|
+
gitRoot: gitCwd,
|
|
2774
|
+
requestClosed,
|
|
2775
|
+
});
|
|
2776
|
+
if (!gitCwd) {
|
|
2777
|
+
return { payload: { path: requestedPath ?? null, diff: '', error: 'Not a git repository' }, pathspec: null };
|
|
2778
|
+
}
|
|
2779
|
+
const pathspec = requestedPath ? await toGitPathspec(gitCwd, requestedPath) : null;
|
|
2780
|
+
const skippedFiles = [];
|
|
2781
|
+
const buildDiffArgs = (includeCached) => {
|
|
2782
|
+
const args = ['diff', '-M', ...diffOptionArgs];
|
|
2783
|
+
if (includeCached)
|
|
2784
|
+
args.push('--cached');
|
|
2785
|
+
if (pathspec)
|
|
2786
|
+
args.push('--', pathspec);
|
|
2787
|
+
return args;
|
|
2788
|
+
};
|
|
2789
|
+
// Default sidebar diffs should represent every changed file from the list:
|
|
2790
|
+
// staged-only changes, unstaged changes, and untracked files. Plain
|
|
2791
|
+
// `git diff` only shows unstaged tracked edits, which made staged-only
|
|
2792
|
+
// additions/deletions/renames look empty in the UI.
|
|
2793
|
+
let truncatedByGit = false;
|
|
2794
|
+
const readLimitedDiff = async (args, maxBytes = MAX_DIFF_BYTES) => {
|
|
2795
|
+
const gitResult = await execGitLimited(args, gitCwd, maxBytes, false, controller.signal, { id: requestId, action, op: 'git.diff', path: requestedPath, extra: { traceId, interactionId, requestSlotId } });
|
|
2796
|
+
if (gitResult.truncated)
|
|
2797
|
+
truncatedByGit = true;
|
|
2798
|
+
return gitResult.stdout;
|
|
2799
|
+
};
|
|
2800
|
+
const readLimitedNoIndexDiff = async (args, maxBytes = MAX_DIFF_BYTES) => {
|
|
2801
|
+
const gitResult = await execGitLimited(args, gitCwd, maxBytes, true, controller.signal, { id: requestId, action, op: 'git.diff', path: requestedPath, extra: { traceId, interactionId, requestSlotId } });
|
|
2802
|
+
if (gitResult.truncated)
|
|
2803
|
+
truncatedByGit = true;
|
|
2804
|
+
return gitResult.stdout;
|
|
2805
|
+
};
|
|
2806
|
+
let diff = cached
|
|
2807
|
+
? await readLimitedDiff(buildDiffArgs(true))
|
|
2808
|
+
: [
|
|
2809
|
+
await readLimitedDiff(buildDiffArgs(true)),
|
|
2810
|
+
await readLimitedDiff(buildDiffArgs(false)),
|
|
2811
|
+
].filter(Boolean).join('\n');
|
|
2812
|
+
let totalBytes = getDiffByteLength(diff);
|
|
2813
|
+
// If git diff produced no output for a specific file, it might be an
|
|
2814
|
+
// untracked (new) file. Use `git diff --no-index /dev/null <path>` to
|
|
2815
|
+
// show the entire file contents as additions, but never for very large
|
|
2816
|
+
// files — that would turn a preview request into a full-file transfer.
|
|
2817
|
+
// Note: git diff --no-index exits with code 1 when there are differences,
|
|
2818
|
+
// but stdout still contains the valid diff text.
|
|
2819
|
+
if (!diff && requestedPath && !cached && pathspec) {
|
|
2820
|
+
const size = await getRelativeFileSize(gitCwd, pathspec);
|
|
2821
|
+
if (size !== null && size > MAX_UNTRACKED_DIFF_FILE_BYTES) {
|
|
2822
|
+
skippedFiles.push(makeSkippedUntracked(pathspec, size));
|
|
2823
|
+
}
|
|
2824
|
+
else {
|
|
2825
|
+
diff = await readLimitedNoIndexDiff(['diff', '--no-index', ...diffOptionArgs, '--', '/dev/null', pathspec]);
|
|
2826
|
+
totalBytes = getDiffByteLength(diff);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
// When viewing the full repo diff (no specific file), also append diffs
|
|
2830
|
+
// for untracked files — `git diff` silently skips them. Keep both per-file
|
|
2831
|
+
// and aggregate byte caps so an accidental large generated file doesn't
|
|
2832
|
+
// dominate the network response or freeze react-diff-view parsing.
|
|
2833
|
+
if (!requestedPath && !cached && totalBytes <= MAX_DIFF_BYTES) {
|
|
2834
|
+
const untracked = await execGit(['ls-files', '--others', '--exclude-standard', '-z'], gitCwd, controller.signal).catch(emptyOnNonAbortGitError);
|
|
2835
|
+
for (const p of untracked.split('\0').filter(Boolean)) {
|
|
2836
|
+
const size = await getRelativeFileSize(gitCwd, p);
|
|
2837
|
+
if (size !== null && size > MAX_UNTRACKED_DIFF_FILE_BYTES) {
|
|
2838
|
+
skippedFiles.push(makeSkippedUntracked(p, size));
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
const partial = await readLimitedNoIndexDiff(['diff', '--no-index', ...diffOptionArgs, '--', '/dev/null', p], MAX_UNTRACKED_DIFF_FILE_BYTES);
|
|
2842
|
+
if (!partial)
|
|
2843
|
+
continue;
|
|
2844
|
+
const nextDiff = diff ? `${diff}\n${partial}` : partial;
|
|
2845
|
+
const nextBytes = getDiffByteLength(nextDiff);
|
|
2846
|
+
if (nextBytes > MAX_DIFF_BYTES) {
|
|
2847
|
+
skippedFiles.push({ path: p, reason: 'diff-byte-limit-exceeded', size: getDiffByteLength(partial), maxBytes: MAX_DIFF_BYTES });
|
|
2848
|
+
break;
|
|
2849
|
+
}
|
|
2850
|
+
diff = nextDiff;
|
|
2851
|
+
totalBytes = nextBytes;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
return {
|
|
2855
|
+
payload: truncateDiffIfNeeded({
|
|
2856
|
+
path: requestedPath ?? null,
|
|
2857
|
+
diff: truncatedByGit ? '' : diff,
|
|
2858
|
+
skippedFiles: skippedFiles.length > 0 ? skippedFiles : undefined,
|
|
2859
|
+
truncated: skippedFiles.length > 0 || truncatedByGit ? true : undefined,
|
|
2860
|
+
tooLarge: truncatedByGit ? true : undefined,
|
|
2861
|
+
maxBytes: truncatedByGit ? MAX_DIFF_BYTES : undefined,
|
|
2862
|
+
}),
|
|
2863
|
+
pathspec,
|
|
2864
|
+
};
|
|
2865
|
+
})(), diffTimeoutMs, diffTimeoutMessage, 'GIT_DIFF_TIMEOUT', () => controller.abort(new OperationTimeoutError(diffTimeoutMessage, 'GIT_DIFF_TIMEOUT')));
|
|
2866
|
+
logFsIo({
|
|
2867
|
+
id: requestId,
|
|
2868
|
+
action,
|
|
2869
|
+
op: 'git.diff',
|
|
2870
|
+
startedAt,
|
|
2871
|
+
status: result.payload.error ? 'error' : 'ok',
|
|
2872
|
+
path: requestedPath,
|
|
2873
|
+
cwd,
|
|
2874
|
+
repoRoot: gitCwdForLog,
|
|
2875
|
+
bytes: getDiffByteLength(result.payload.diff),
|
|
2876
|
+
truncated: Boolean(result.payload.truncated),
|
|
2877
|
+
code: result.payload.error ? 'GIT_DIFF_ERROR' : undefined,
|
|
2878
|
+
error: result.payload.error,
|
|
2879
|
+
extra: { traceId, interactionId, pathspec: result.pathspec, tooLarge: Boolean(result.payload.tooLarge) },
|
|
2880
|
+
});
|
|
2881
|
+
writeDiffTraceLog({
|
|
2882
|
+
source: 'server.git-diff',
|
|
2883
|
+
event: result.payload.error ? 'response-error' : 'response-ok',
|
|
2884
|
+
requestId,
|
|
2885
|
+
action,
|
|
2886
|
+
traceId,
|
|
2887
|
+
interactionId,
|
|
2888
|
+
requestSlotId,
|
|
2889
|
+
filePath: requestedPath,
|
|
2890
|
+
cwd,
|
|
2891
|
+
gitRoot: gitCwdForLog,
|
|
2892
|
+
durationMs: Date.now() - startedAt,
|
|
2893
|
+
bytes: getDiffByteLength(result.payload.diff),
|
|
2894
|
+
truncated: Boolean(result.payload.truncated),
|
|
2895
|
+
error: result.payload.error,
|
|
2896
|
+
pathspec: result.pathspec,
|
|
2897
|
+
tooLarge: Boolean(result.payload.tooLarge),
|
|
2898
|
+
});
|
|
2899
|
+
res.json(result.payload);
|
|
2900
|
+
}
|
|
2901
|
+
catch (error) {
|
|
2902
|
+
const payload = getErrorPayload(error);
|
|
2903
|
+
logFsIo({ id: requestId, action, op: 'git.diff', startedAt, status: 'error', path: requestedPath, cwd, repoRoot: gitCwdForLog, code: payload.code, error: payload.error, extra: { traceId, interactionId, requestClosed } });
|
|
2904
|
+
writeDiffTraceLog({
|
|
2905
|
+
source: 'server.git-diff',
|
|
2906
|
+
event: 'response-exception',
|
|
2907
|
+
requestId,
|
|
2908
|
+
action,
|
|
2909
|
+
traceId,
|
|
2910
|
+
interactionId,
|
|
2911
|
+
requestSlotId,
|
|
2912
|
+
filePath: requestedPath,
|
|
2913
|
+
cwd,
|
|
2914
|
+
gitRoot: gitCwdForLog,
|
|
2915
|
+
durationMs: Date.now() - startedAt,
|
|
2916
|
+
error: payload.error,
|
|
2917
|
+
code: payload.code,
|
|
2918
|
+
requestClosed,
|
|
2919
|
+
});
|
|
2920
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ path: req.query.path ?? null, diff: '', ...payload });
|
|
2921
|
+
}
|
|
2922
|
+
finally {
|
|
2923
|
+
if (requestSlotId && activeDiffSlots.get(requestSlotId)?.requestId === requestId) {
|
|
2924
|
+
activeDiffSlots.delete(requestSlotId);
|
|
2925
|
+
}
|
|
2926
|
+
updateInflight('git.diff', -1);
|
|
2927
|
+
logFsIoEvent({ id: requestId, action, op: 'git.diff', event: 'request-end', path: requestedPath, cwd, repoRoot: gitCwdForLog, requestClosed, extra: { traceId, interactionId, requestSlotId } });
|
|
2928
|
+
writeDiffTraceLog({
|
|
2929
|
+
source: 'server.git-diff',
|
|
2930
|
+
event: 'request-end',
|
|
2931
|
+
requestId,
|
|
2932
|
+
action,
|
|
2933
|
+
traceId,
|
|
2934
|
+
interactionId,
|
|
2935
|
+
requestSlotId,
|
|
2936
|
+
filePath: requestedPath,
|
|
2937
|
+
cwd,
|
|
2938
|
+
gitRoot: gitCwdForLog,
|
|
2939
|
+
requestClosed,
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
});
|
|
2943
|
+
// List changed files across staged, unstaged, and untracked state.
|
|
2944
|
+
router.get('/diff-files', async (req, res) => {
|
|
2945
|
+
const requestId = ++fsIoRequestSeq;
|
|
2946
|
+
const startedAt = Date.now();
|
|
2947
|
+
const cwd = req.query.cwd;
|
|
2948
|
+
const action = getRequestAction(req, 'load_diff_files');
|
|
2949
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2950
|
+
const controller = new AbortController();
|
|
2951
|
+
const abortRequest = () => {
|
|
2952
|
+
if (!res.writableEnded)
|
|
2953
|
+
controller.abort(new SupersededRequestError('git.diff-files'));
|
|
2954
|
+
};
|
|
2955
|
+
req.on('aborted', abortRequest);
|
|
2956
|
+
res.on('close', abortRequest);
|
|
2957
|
+
registerIoSlot({ requestId, op: 'git.diff-files', action, slotId: requestSlotId, controller, cwd });
|
|
2958
|
+
let gitRootForLog = null;
|
|
2959
|
+
try {
|
|
2960
|
+
if (!cwd) {
|
|
2961
|
+
res.json({ files: [], error: 'No cwd provided' });
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
2965
|
+
throwIfAborted(controller.signal, 'git.diff-files');
|
|
2966
|
+
const gitCwd = await findGitRoot(resolvedCwd);
|
|
2967
|
+
throwIfAborted(controller.signal, 'git.diff-files');
|
|
2968
|
+
gitRootForLog = gitCwd;
|
|
2969
|
+
if (!gitCwd) {
|
|
2970
|
+
res.json({ files: [], error: 'Not a git repository' });
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
const changedResult = await withTimeout(getChangedFiles(gitCwd, controller.signal, { includeUntracked: false }), GIT_ROUTE_TIMEOUT_MS, 'Git file list took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_DIFF_FILES_TIMEOUT', () => controller.abort(new OperationTimeoutError('Git file list took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_DIFF_FILES_TIMEOUT')));
|
|
2974
|
+
const files = changedResult.files;
|
|
2975
|
+
logFsIo({ id: requestId, action, op: 'git.diff-files', startedAt, status: 'ok', cwd: resolvedCwd, repoRoot: gitCwd, count: files.length, extra: { requestSlotId } });
|
|
2976
|
+
res.json({ files });
|
|
2977
|
+
}
|
|
2978
|
+
catch (error) {
|
|
2979
|
+
const payload = getErrorPayload(error);
|
|
2980
|
+
logFsIo({ id: requestId, action, op: 'git.diff-files', startedAt, status: 'error', cwd, repoRoot: gitRootForLog, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
2981
|
+
res.json({ files: [], ...payload });
|
|
2982
|
+
}
|
|
2983
|
+
finally {
|
|
2984
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
router.get('/untracked-files', async (req, res) => {
|
|
2988
|
+
const requestId = ++fsIoRequestSeq;
|
|
2989
|
+
const startedAt = Date.now();
|
|
2990
|
+
const cwd = req.query.cwd;
|
|
2991
|
+
const action = getRequestAction(req, 'load_untracked_files');
|
|
2992
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
2993
|
+
let gitRootForLog = null;
|
|
2994
|
+
try {
|
|
2995
|
+
if (!cwd) {
|
|
2996
|
+
res.json({ status: 'error', files: [], error: 'No cwd provided' });
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
3000
|
+
const gitRoot = await findGitRoot(resolvedCwd);
|
|
3001
|
+
gitRootForLog = gitRoot;
|
|
3002
|
+
if (!gitRoot) {
|
|
3003
|
+
res.json({ status: 'error', files: [], error: 'Not a git repository' });
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
let nestedDisplayRoots = new Set();
|
|
3007
|
+
try {
|
|
3008
|
+
const discovered = await getCachedNestedGitRoots(gitRoot);
|
|
3009
|
+
nestedDisplayRoots = buildNestedRepoDisplayRootSet(gitRoot, discovered.repositories);
|
|
3010
|
+
}
|
|
3011
|
+
catch {
|
|
3012
|
+
// Best-effort only. The untracked scan should still return useful data
|
|
3013
|
+
// even if nested repository discovery is cancelled or times out.
|
|
3014
|
+
}
|
|
3015
|
+
const payload = startUntrackedJob(gitRoot, nestedDisplayRoots);
|
|
3016
|
+
logFsIo({
|
|
3017
|
+
id: requestId,
|
|
3018
|
+
action,
|
|
3019
|
+
op: 'git.untracked',
|
|
3020
|
+
startedAt,
|
|
3021
|
+
status: payload.status === 'error' ? 'error' : 'ok',
|
|
3022
|
+
cwd: resolvedCwd,
|
|
3023
|
+
repoRoot: gitRoot,
|
|
3024
|
+
count: payload.files.length,
|
|
3025
|
+
code: payload.code,
|
|
3026
|
+
error: payload.error,
|
|
3027
|
+
extra: { requestSlotId, jobStatus: payload.status },
|
|
3028
|
+
});
|
|
3029
|
+
res.json(payload);
|
|
3030
|
+
}
|
|
3031
|
+
catch (error) {
|
|
3032
|
+
const payload = getErrorPayload(error);
|
|
3033
|
+
logFsIo({ id: requestId, action, op: 'git.untracked', startedAt, status: 'error', cwd, repoRoot: gitRootForLog, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
3034
|
+
res.status(200).json({ status: 'error', files: [], ...payload });
|
|
3035
|
+
}
|
|
3036
|
+
});
|
|
3037
|
+
// Lightweight git context for AI prompts / multi-agent handoff
|
|
3038
|
+
router.get('/git-context', async (req, res) => {
|
|
3039
|
+
const requestId = ++fsIoRequestSeq;
|
|
3040
|
+
const startedAt = Date.now();
|
|
3041
|
+
const cwd = req.query.cwd;
|
|
3042
|
+
const action = getRequestAction(req, 'load_git_details');
|
|
3043
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
3044
|
+
const controller = new AbortController();
|
|
3045
|
+
const abortRequest = () => {
|
|
3046
|
+
if (!res.writableEnded)
|
|
3047
|
+
controller.abort(new SupersededRequestError('git.context'));
|
|
3048
|
+
};
|
|
3049
|
+
req.on('aborted', abortRequest);
|
|
3050
|
+
res.on('close', abortRequest);
|
|
3051
|
+
let gitRootForLog = null;
|
|
3052
|
+
registerIoSlot({ requestId, op: 'git.context', action, slotId: requestSlotId, controller, cwd });
|
|
3053
|
+
try {
|
|
3054
|
+
if (!cwd) {
|
|
3055
|
+
res.json({ available: false, error: 'No cwd provided' });
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
3059
|
+
throwIfAborted(controller.signal, 'git.context');
|
|
3060
|
+
const gitRoot = await findGitRoot(resolvedCwd);
|
|
3061
|
+
throwIfAborted(controller.signal, 'git.context');
|
|
3062
|
+
gitRootForLog = gitRoot;
|
|
3063
|
+
if (!gitRoot) {
|
|
3064
|
+
logFsIo({ action, op: 'git.context', startedAt, status: 'error', cwd: resolvedCwd, repoRoot: null, code: 'NOT_GIT_REPOSITORY', error: 'Not a git repository' });
|
|
3065
|
+
res.json({ available: false, cwd: resolvedCwd, error: 'Not a git repository', code: 'NOT_GIT_REPOSITORY' });
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
const result = await withTimeout((async () => {
|
|
3069
|
+
throwIfAborted(controller.signal, 'git.context');
|
|
3070
|
+
const [branchOutput, statusOutput, pushTargets] = await Promise.all([
|
|
3071
|
+
execGit(['branch', '--show-current'], gitRoot, controller.signal).catch(emptyOnNonAbortGitError),
|
|
3072
|
+
execGit(['status', '--short', '--branch'], gitRoot, controller.signal).catch(emptyOnNonAbortGitError),
|
|
3073
|
+
getGitPushTargets(gitRoot, controller.signal),
|
|
3074
|
+
]);
|
|
3075
|
+
throwIfAborted(controller.signal, 'git.context');
|
|
3076
|
+
const files = (await getChangedFiles(gitRoot, controller.signal, { includeUntracked: false })).files;
|
|
3077
|
+
throwIfAborted(controller.signal, 'git.context');
|
|
3078
|
+
const changedFiles = toContextFiles(files);
|
|
3079
|
+
return { branchOutput, statusOutput, pushTargets, changedFiles };
|
|
3080
|
+
})(), GIT_ROUTE_TIMEOUT_MS, 'Git details took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_CONTEXT_TIMEOUT', () => controller.abort(new OperationTimeoutError('Git details took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_CONTEXT_TIMEOUT')));
|
|
3081
|
+
logFsIo({ id: requestId, action, op: 'git.context', startedAt, status: 'ok', cwd: resolvedCwd, repoRoot: gitRoot, count: result.changedFiles.length, truncated: result.changedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES, extra: { requestSlotId } });
|
|
3082
|
+
res.json({
|
|
3083
|
+
available: true,
|
|
3084
|
+
cwd: resolvedCwd,
|
|
3085
|
+
root: gitRoot,
|
|
3086
|
+
branch: result.branchOutput.trim() || null,
|
|
3087
|
+
...result.pushTargets,
|
|
3088
|
+
status: result.statusOutput.trim(),
|
|
3089
|
+
changedFiles: result.changedFiles,
|
|
3090
|
+
truncated: result.changedFiles.length >= MAX_GIT_CONTEXT_CHANGED_FILES,
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
catch (error) {
|
|
3094
|
+
const payload = getErrorPayload(error);
|
|
3095
|
+
logFsIo({ id: requestId, action, op: 'git.context', startedAt, status: 'error', cwd, repoRoot: gitRootForLog, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
3096
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ available: false, ...payload });
|
|
3097
|
+
}
|
|
3098
|
+
finally {
|
|
3099
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
3100
|
+
}
|
|
3101
|
+
});
|
|
3102
|
+
// Combined payload for sidebar open — returns diff-files and git-context
|
|
3103
|
+
// in one round-trip. Front-end fires this once when opening the right
|
|
3104
|
+
// sidebar instead of two parallel requests, and the server reuses the
|
|
3105
|
+
// resolved git root rather than running `rev-parse` twice.
|
|
3106
|
+
router.get('/git-bundle', async (req, res) => {
|
|
3107
|
+
const requestId = ++fsIoRequestSeq;
|
|
3108
|
+
const inflight = updateInflight('git.bundle', 1);
|
|
3109
|
+
const startedAt = Date.now();
|
|
3110
|
+
const cwd = req.query.cwd;
|
|
3111
|
+
const action = getRequestAction(req, req.query.refresh === 'true' ? 'manual_git_refresh' : 'open_sidebar_git_refresh');
|
|
3112
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
3113
|
+
const controller = new AbortController();
|
|
3114
|
+
const abortRequest = () => {
|
|
3115
|
+
if (!res.writableEnded)
|
|
3116
|
+
controller.abort(new SupersededRequestError('git.bundle'));
|
|
3117
|
+
};
|
|
3118
|
+
req.on('aborted', abortRequest);
|
|
3119
|
+
res.on('close', abortRequest);
|
|
3120
|
+
let gitRootForLog = null;
|
|
3121
|
+
registerIoSlot({ requestId, op: 'git.bundle', action, slotId: requestSlotId, controller, cwd });
|
|
3122
|
+
logFsIoEvent({ id: requestId, action, op: 'git.bundle', event: 'request-start', cwd, extra: { inflight, requestSlotId, includeNestedQuery: req.query.includeNested === 'true', refreshQuery: req.query.refresh === 'true' } });
|
|
3123
|
+
try {
|
|
3124
|
+
const refresh = req.query.refresh === 'true';
|
|
3125
|
+
const cacheOnly = req.query.cacheOnly === 'true';
|
|
3126
|
+
const includeNested = req.query.includeNested === 'true';
|
|
3127
|
+
if (!cwd) {
|
|
3128
|
+
res.json({ available: false, files: [], context: null, error: 'No cwd provided' });
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
3132
|
+
throwIfAborted(controller.signal, 'git.bundle');
|
|
3133
|
+
const gitRoot = await findGitRoot(resolvedCwd, refresh ? null : GIT_TIMEOUT_MS);
|
|
3134
|
+
throwIfAborted(controller.signal, 'git.bundle');
|
|
3135
|
+
gitRootForLog = gitRoot;
|
|
3136
|
+
if (!gitRoot) {
|
|
3137
|
+
const payload = {
|
|
3138
|
+
available: false,
|
|
3139
|
+
files: [],
|
|
3140
|
+
context: { available: false, cwd: resolvedCwd, error: 'Not a git repository', code: 'NOT_GIT_REPOSITORY' },
|
|
3141
|
+
code: 'NOT_GIT_REPOSITORY',
|
|
3142
|
+
};
|
|
3143
|
+
logFsIo({ id: requestId, action, op: 'git.bundle', startedAt, status: 'error', cwd: resolvedCwd, repoRoot: null, code: 'NOT_GIT_REPOSITORY', error: 'Not a git repository' });
|
|
3144
|
+
res.json(payload);
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
logFsIoEvent({ id: requestId, action, op: 'git.bundle', event: 'git-root-resolved', cwd: resolvedCwd, repoRoot: gitRoot, extra: { requestSlotId, includeNested, refresh, cacheOnly } });
|
|
3148
|
+
const allowStale = !refresh;
|
|
3149
|
+
const cachedBundle = allowStale && includeNested ? getGitBundleCache(gitRoot, true, true) : null;
|
|
3150
|
+
const effectiveIncludeNested = includeNested;
|
|
3151
|
+
const nestedDeferred = false;
|
|
3152
|
+
if (cacheOnly) {
|
|
3153
|
+
const bundle = cachedBundle ?? getGitBundleCache(gitRoot, effectiveIncludeNested, true);
|
|
3154
|
+
const payload = bundle ?? {
|
|
3155
|
+
available: true,
|
|
3156
|
+
files: [],
|
|
3157
|
+
context: { available: true, cwd: resolvedCwd, root: gitRoot, changedFiles: [] },
|
|
3158
|
+
repositories: [],
|
|
3159
|
+
repoFilters: [],
|
|
3160
|
+
cached: false,
|
|
3161
|
+
stale: true,
|
|
3162
|
+
cacheAgeMs: undefined,
|
|
3163
|
+
};
|
|
3164
|
+
logFsIo({
|
|
3165
|
+
id: requestId,
|
|
3166
|
+
action,
|
|
3167
|
+
op: 'git.bundle',
|
|
3168
|
+
startedAt,
|
|
3169
|
+
status: payload.error ? 'error' : 'ok',
|
|
3170
|
+
cwd: resolvedCwd,
|
|
3171
|
+
repoRoot: gitRoot,
|
|
3172
|
+
count: payload.files.length,
|
|
3173
|
+
code: payload.error ? 'GIT_BUNDLE_ERROR' : undefined,
|
|
3174
|
+
error: payload.error,
|
|
3175
|
+
truncated: Boolean(payload.truncatedRepositories),
|
|
3176
|
+
extra: { requestSlotId, repositories: payload.repositories?.length ?? 1, includeNested: effectiveIncludeNested, requestedIncludeNested: includeNested, nestedDeferred, untrackedDeferred: Boolean(payload.untrackedDeferred), refresh, cacheOnly, cached: Boolean(payload.cached), stale: Boolean(payload.stale), cacheAgeMs: payload.cacheAgeMs },
|
|
3177
|
+
});
|
|
3178
|
+
res.json(payload);
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
const bundle = cachedBundle ?? (refresh
|
|
3182
|
+
? await refreshGitBundleCacheDetached(resolvedCwd, gitRoot, effectiveIncludeNested, { gitTimeoutMs: null })
|
|
3183
|
+
: await withTimeout(getCachedGitBundle(resolvedCwd, gitRoot, effectiveIncludeNested, false, allowStale, controller.signal), GIT_ROUTE_TIMEOUT_MS, 'Git status refresh took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_BUNDLE_TIMEOUT', () => controller.abort(new OperationTimeoutError('Git status refresh took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_BUNDLE_TIMEOUT'))));
|
|
3184
|
+
logFsIo({
|
|
3185
|
+
id: requestId,
|
|
3186
|
+
action,
|
|
3187
|
+
op: 'git.bundle',
|
|
3188
|
+
startedAt,
|
|
3189
|
+
status: bundle.error ? 'error' : 'ok',
|
|
3190
|
+
cwd: resolvedCwd,
|
|
3191
|
+
repoRoot: gitRoot,
|
|
3192
|
+
count: bundle.files.length,
|
|
3193
|
+
code: bundle.error ? 'GIT_BUNDLE_ERROR' : undefined,
|
|
3194
|
+
error: bundle.error,
|
|
3195
|
+
truncated: Boolean(bundle.truncatedRepositories),
|
|
3196
|
+
extra: { requestSlotId, repositories: bundle.repositories?.length ?? 1, includeNested: effectiveIncludeNested, requestedIncludeNested: includeNested, nestedDeferred, untrackedDeferred: Boolean(bundle.untrackedDeferred), refresh, cacheOnly, cached: Boolean(bundle.cached), stale: Boolean(bundle.stale), cacheAgeMs: bundle.cacheAgeMs },
|
|
3197
|
+
});
|
|
3198
|
+
res.json(bundle);
|
|
3199
|
+
}
|
|
3200
|
+
catch (error) {
|
|
3201
|
+
const payload = getErrorPayload(error);
|
|
3202
|
+
logFsIo({ id: requestId, action, op: 'git.bundle', startedAt, status: 'error', cwd, repoRoot: gitRootForLog, code: payload.code, error: payload.error, extra: { requestSlotId } });
|
|
3203
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ available: false, files: [], context: null, ...payload });
|
|
3204
|
+
}
|
|
3205
|
+
finally {
|
|
3206
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
3207
|
+
updateInflight('git.bundle', -1);
|
|
3208
|
+
logFsIoEvent({ id: requestId, action, op: 'git.bundle', event: 'request-end', cwd, repoRoot: gitRootForLog, extra: { requestSlotId } });
|
|
3209
|
+
}
|
|
3210
|
+
});
|
|
3211
|
+
router.get('/change-audit', (req, res) => {
|
|
3212
|
+
const workspaceRoot = typeof req.query.workspaceRoot === 'string' ? req.query.workspaceRoot : null;
|
|
3213
|
+
const repoRoot = typeof req.query.repoRoot === 'string' ? req.query.repoRoot : null;
|
|
3214
|
+
res.json(listChangeAuditRecords({ workspaceRoot, repoRoot }));
|
|
3215
|
+
});
|
|
3216
|
+
router.delete('/change-audit', (req, res) => {
|
|
3217
|
+
const body = req.body;
|
|
3218
|
+
const ids = Array.isArray(body.ids)
|
|
3219
|
+
? body.ids.filter((id) => typeof id === 'string' && id.length > 0)
|
|
3220
|
+
: undefined;
|
|
3221
|
+
const workspaceRoot = typeof body.workspaceRoot === 'string' ? body.workspaceRoot : null;
|
|
3222
|
+
const repoRoot = typeof body.repoRoot === 'string' ? body.repoRoot : null;
|
|
3223
|
+
if ((!ids || ids.length === 0) && !workspaceRoot && !repoRoot) {
|
|
3224
|
+
res.status(400).json({ error: 'Expected ids, workspaceRoot, or repoRoot to clear change audit explanations' });
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
res.json({ ok: true, ...clearChangeAuditRecords({ ids, workspaceRoot, repoRoot }) });
|
|
3228
|
+
});
|
|
3229
|
+
router.get('/branch-diff', async (req, res) => {
|
|
3230
|
+
const requestId = ++fsIoRequestSeq;
|
|
3231
|
+
const startedAt = Date.now();
|
|
3232
|
+
const cwd = typeof req.query.cwd === 'string' ? req.query.cwd : undefined;
|
|
3233
|
+
const requestedRepoRoot = typeof req.query.repoRoot === 'string' ? req.query.repoRoot : undefined;
|
|
3234
|
+
const baseBranch = typeof req.query.base === 'string' ? req.query.base : '';
|
|
3235
|
+
const headRef = typeof req.query.head === 'string' ? req.query.head : null;
|
|
3236
|
+
const includeUncommitted = req.query.includeUncommitted !== '0' && req.query.includeUncommitted !== 'false';
|
|
3237
|
+
const action = getRequestAction(req, 'load_branch_diff');
|
|
3238
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
3239
|
+
const controller = new AbortController();
|
|
3240
|
+
const abortRequest = () => {
|
|
3241
|
+
if (!res.writableEnded)
|
|
3242
|
+
controller.abort(new SupersededRequestError('git.branch-diff'));
|
|
3243
|
+
};
|
|
3244
|
+
req.on('aborted', abortRequest);
|
|
3245
|
+
res.on('close', abortRequest);
|
|
3246
|
+
let repoRootForLog = null;
|
|
3247
|
+
registerIoSlot({ requestId, op: 'git.branch-diff', action, slotId: requestSlotId, controller, cwd: requestedRepoRoot ?? cwd });
|
|
3248
|
+
try {
|
|
3249
|
+
if (!cwd && !requestedRepoRoot) {
|
|
3250
|
+
res.json({ available: false, error: 'No cwd provided' });
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd ?? requestedRepoRoot ?? '');
|
|
3254
|
+
throwIfAborted(controller.signal, 'git.branch-diff');
|
|
3255
|
+
const workspaceGitRoot = await findGitRoot(resolvedCwd);
|
|
3256
|
+
throwIfAborted(controller.signal, 'git.branch-diff');
|
|
3257
|
+
if (!workspaceGitRoot) {
|
|
3258
|
+
res.json({ available: false, workspaceRoot: resolvedCwd, error: 'Not a git repository' });
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
const repoRoot = requestedRepoRoot ? await pathValidator.validatePathAsync(requestedRepoRoot) : workspaceGitRoot;
|
|
3262
|
+
if (!(await isWorkspaceGitRepositoryRoot(workspaceGitRoot, repoRoot, controller.signal))) {
|
|
3263
|
+
res.status(400).json({ available: false, error: 'Repository is outside current workspace' });
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
const actualRepoRoot = await findGitRoot(repoRoot);
|
|
3267
|
+
if (!actualRepoRoot || actualRepoRoot !== repoRoot) {
|
|
3268
|
+
res.status(400).json({ available: false, error: 'Repository root is invalid' });
|
|
3269
|
+
return;
|
|
3270
|
+
}
|
|
3271
|
+
repoRootForLog = repoRoot;
|
|
3272
|
+
const payload = await withTimeout(getBranchDiffPayload(workspaceGitRoot, repoRoot, baseBranch, { headRef, includeUncommitted }, controller.signal), GIT_FILE_DIFF_ROUTE_TIMEOUT_MS, 'Branch diff took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_BRANCH_DIFF_TIMEOUT', () => controller.abort(new OperationTimeoutError('Branch diff took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_BRANCH_DIFF_TIMEOUT')));
|
|
3273
|
+
logFsIo({
|
|
3274
|
+
id: requestId,
|
|
3275
|
+
action,
|
|
3276
|
+
op: 'git.branch-diff',
|
|
3277
|
+
startedAt,
|
|
3278
|
+
status: payload.available ? 'ok' : 'error',
|
|
3279
|
+
cwd: resolvedCwd,
|
|
3280
|
+
repoRoot,
|
|
3281
|
+
count: payload.files?.length ?? 0,
|
|
3282
|
+
error: payload.error,
|
|
3283
|
+
truncated: payload.truncated,
|
|
3284
|
+
extra: { baseBranch, headRef, includeUncommitted, requestSlotId, commits: payload.commitCount },
|
|
3285
|
+
});
|
|
3286
|
+
res.json(payload);
|
|
3287
|
+
}
|
|
3288
|
+
catch (error) {
|
|
3289
|
+
const payload = getErrorPayload(error);
|
|
3290
|
+
logFsIo({ id: requestId, action, op: 'git.branch-diff', startedAt, status: 'error', cwd, repoRoot: repoRootForLog, code: payload.code, error: payload.error, extra: { baseBranch, requestSlotId } });
|
|
3291
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ available: false, ...payload });
|
|
3292
|
+
}
|
|
3293
|
+
finally {
|
|
3294
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
3295
|
+
}
|
|
3296
|
+
});
|
|
3297
|
+
router.get('/commit-diff', async (req, res) => {
|
|
3298
|
+
const requestId = ++fsIoRequestSeq;
|
|
3299
|
+
const startedAt = Date.now();
|
|
3300
|
+
const cwd = typeof req.query.cwd === 'string' ? req.query.cwd : undefined;
|
|
3301
|
+
const requestedRepoRoot = typeof req.query.repoRoot === 'string' ? req.query.repoRoot : undefined;
|
|
3302
|
+
const commit = typeof req.query.commit === 'string' ? req.query.commit : '';
|
|
3303
|
+
const action = getRequestAction(req, 'load_commit_diff');
|
|
3304
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
3305
|
+
const controller = new AbortController();
|
|
3306
|
+
const abortRequest = () => {
|
|
3307
|
+
if (!res.writableEnded)
|
|
3308
|
+
controller.abort(new SupersededRequestError('git.commit-diff'));
|
|
3309
|
+
};
|
|
3310
|
+
req.on('aborted', abortRequest);
|
|
3311
|
+
res.on('close', abortRequest);
|
|
3312
|
+
let repoRootForLog = null;
|
|
3313
|
+
registerIoSlot({ requestId, op: 'git.commit-diff', action, slotId: requestSlotId, controller, cwd: requestedRepoRoot ?? cwd });
|
|
3314
|
+
try {
|
|
3315
|
+
if ((!cwd && !requestedRepoRoot) || !commit.trim()) {
|
|
3316
|
+
res.json({ available: false, error: 'No cwd or commit provided' });
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd ?? requestedRepoRoot ?? '');
|
|
3320
|
+
throwIfAborted(controller.signal, 'git.commit-diff');
|
|
3321
|
+
const workspaceGitRoot = await findGitRoot(resolvedCwd);
|
|
3322
|
+
throwIfAborted(controller.signal, 'git.commit-diff');
|
|
3323
|
+
if (!workspaceGitRoot) {
|
|
3324
|
+
res.json({ available: false, workspaceRoot: resolvedCwd, error: 'Not a git repository' });
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
const repoRoot = requestedRepoRoot ? await pathValidator.validatePathAsync(requestedRepoRoot) : workspaceGitRoot;
|
|
3328
|
+
if (!(await isWorkspaceGitRepositoryRoot(workspaceGitRoot, repoRoot, controller.signal))) {
|
|
3329
|
+
res.status(400).json({ available: false, error: 'Repository is outside current workspace' });
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
const actualRepoRoot = await findGitRoot(repoRoot);
|
|
3333
|
+
if (!actualRepoRoot || actualRepoRoot !== repoRoot) {
|
|
3334
|
+
res.status(400).json({ available: false, error: 'Repository root is invalid' });
|
|
3335
|
+
return;
|
|
3336
|
+
}
|
|
3337
|
+
repoRootForLog = repoRoot;
|
|
3338
|
+
const payload = await withTimeout(getCommitDiffPayload(workspaceGitRoot, repoRoot, commit, controller.signal), GIT_FILE_DIFF_ROUTE_TIMEOUT_MS, 'Commit diff took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_COMMIT_DIFF_TIMEOUT', () => controller.abort(new OperationTimeoutError('Commit diff took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_COMMIT_DIFF_TIMEOUT')));
|
|
3339
|
+
logFsIo({
|
|
3340
|
+
id: requestId,
|
|
3341
|
+
action,
|
|
3342
|
+
op: 'git.commit-diff',
|
|
3343
|
+
startedAt,
|
|
3344
|
+
status: payload.available ? 'ok' : 'error',
|
|
3345
|
+
cwd: resolvedCwd,
|
|
3346
|
+
repoRoot,
|
|
3347
|
+
count: payload.files?.length ?? 0,
|
|
3348
|
+
error: payload.error,
|
|
3349
|
+
truncated: payload.truncated,
|
|
3350
|
+
extra: { commit, requestSlotId },
|
|
3351
|
+
});
|
|
3352
|
+
res.json(payload);
|
|
3353
|
+
}
|
|
3354
|
+
catch (error) {
|
|
3355
|
+
const payload = getErrorPayload(error);
|
|
3356
|
+
logFsIo({ id: requestId, action, op: 'git.commit-diff', startedAt, status: 'error', cwd, repoRoot: repoRootForLog, code: payload.code, error: payload.error, extra: { commit, requestSlotId } });
|
|
3357
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ available: false, ...payload });
|
|
3358
|
+
}
|
|
3359
|
+
finally {
|
|
3360
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
3361
|
+
}
|
|
3362
|
+
});
|
|
3363
|
+
router.get('/git-recent-commits', async (req, res) => {
|
|
3364
|
+
const requestId = ++fsIoRequestSeq;
|
|
3365
|
+
const startedAt = Date.now();
|
|
3366
|
+
const cwd = typeof req.query.cwd === 'string' ? req.query.cwd : undefined;
|
|
3367
|
+
const requestedRepoRoot = typeof req.query.repoRoot === 'string' ? req.query.repoRoot : undefined;
|
|
3368
|
+
const query = typeof req.query.query === 'string' ? req.query.query.trim() : '';
|
|
3369
|
+
const rawLimit = Number.parseInt(typeof req.query.limit === 'string' ? req.query.limit : '', 10);
|
|
3370
|
+
const rawSkip = Number.parseInt(typeof req.query.skip === 'string' ? req.query.skip : '', 10);
|
|
3371
|
+
const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 20, 1), MAX_RECENT_COMMITS_LIMIT);
|
|
3372
|
+
const skip = Math.max(Number.isFinite(rawSkip) ? rawSkip : 0, 0);
|
|
3373
|
+
const action = getRequestAction(req, 'load_recent_commits');
|
|
3374
|
+
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
3375
|
+
const controller = new AbortController();
|
|
3376
|
+
const abortRequest = () => {
|
|
3377
|
+
if (!res.writableEnded)
|
|
3378
|
+
controller.abort(new SupersededRequestError('git.recent-commits'));
|
|
3379
|
+
};
|
|
3380
|
+
req.on('aborted', abortRequest);
|
|
3381
|
+
res.on('close', abortRequest);
|
|
3382
|
+
let repoRootForLog = null;
|
|
3383
|
+
registerIoSlot({ requestId, op: 'git.recent-commits', action, slotId: requestSlotId, controller, cwd: requestedRepoRoot ?? cwd });
|
|
3384
|
+
try {
|
|
3385
|
+
if (!cwd && !requestedRepoRoot) {
|
|
3386
|
+
res.json({ available: false, error: 'No cwd provided' });
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd ?? requestedRepoRoot ?? '');
|
|
3390
|
+
throwIfAborted(controller.signal, 'git.recent-commits');
|
|
3391
|
+
const workspaceGitRoot = await findGitRoot(resolvedCwd);
|
|
3392
|
+
throwIfAborted(controller.signal, 'git.recent-commits');
|
|
3393
|
+
if (!workspaceGitRoot) {
|
|
3394
|
+
res.json({ available: false, cwd: resolvedCwd, error: 'Not a git repository', commits: [], hasMore: false });
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
const repoRoot = requestedRepoRoot ? await pathValidator.validatePathAsync(requestedRepoRoot) : workspaceGitRoot;
|
|
3398
|
+
if (!(await isWorkspaceGitRepositoryRoot(workspaceGitRoot, repoRoot, controller.signal))) {
|
|
3399
|
+
res.status(400).json({ available: false, error: 'Repository is outside current workspace', commits: [], hasMore: false });
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const actualRepoRoot = await findGitRoot(repoRoot);
|
|
3403
|
+
if (!actualRepoRoot || actualRepoRoot !== repoRoot) {
|
|
3404
|
+
res.status(400).json({ available: false, error: 'Repository root is invalid', commits: [], hasMore: false });
|
|
3405
|
+
return;
|
|
3406
|
+
}
|
|
3407
|
+
repoRootForLog = repoRoot;
|
|
3408
|
+
const fetchCount = limit + 1;
|
|
3409
|
+
const logArgs = ['log', '--oneline', `--skip=${skip}`, `-${fetchCount}`];
|
|
3410
|
+
if (query) {
|
|
3411
|
+
logArgs.push('--regexp-ignore-case', '--all-match', `--grep=${query}`);
|
|
3412
|
+
}
|
|
3413
|
+
let output = await withTimeout(execGit(logArgs, repoRoot, controller.signal).catch(emptyOnNonAbortGitError), GIT_ROUTE_TIMEOUT_MS, 'Recent commits took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_RECENT_COMMITS_TIMEOUT', () => controller.abort(new OperationTimeoutError('Recent commits took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_RECENT_COMMITS_TIMEOUT')));
|
|
3414
|
+
let commits = output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
3415
|
+
if (query && commits.length === 0 && /^[0-9a-f]{4,40}$/i.test(query)) {
|
|
3416
|
+
const hashSearchLimit = Math.max(fetchCount + skip, 200);
|
|
3417
|
+
output = await withTimeout(execGit(['log', '--oneline', '--abbrev=40', `-${hashSearchLimit}`], repoRoot, controller.signal).catch(emptyOnNonAbortGitError), GIT_ROUTE_TIMEOUT_MS, 'Recent commits took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_RECENT_COMMITS_TIMEOUT', () => controller.abort(new OperationTimeoutError('Recent commits took too long. The repository may be busy, on slow storage, or locked by another Git process.', 'GIT_RECENT_COMMITS_TIMEOUT')));
|
|
3418
|
+
commits = output
|
|
3419
|
+
.split('\n')
|
|
3420
|
+
.map((line) => line.trim())
|
|
3421
|
+
.filter((line) => line.toLowerCase().startsWith(query.toLowerCase()))
|
|
3422
|
+
.slice(skip, skip + fetchCount);
|
|
3423
|
+
}
|
|
3424
|
+
const hasMore = commits.length > limit;
|
|
3425
|
+
const page = commits.slice(0, limit);
|
|
3426
|
+
logFsIo({ id: requestId, action, op: 'git.recent-commits', startedAt, status: 'ok', cwd: resolvedCwd, repoRoot, count: page.length, extra: { requestSlotId, query, skip, limit, hasMore } });
|
|
3427
|
+
res.json({ available: true, cwd: resolvedCwd, root: repoRoot, commits: page, hasMore, skip, limit, query });
|
|
3428
|
+
}
|
|
3429
|
+
catch (error) {
|
|
3430
|
+
const payload = getErrorPayload(error);
|
|
3431
|
+
logFsIo({ id: requestId, action, op: 'git.recent-commits', startedAt, status: 'error', cwd, repoRoot: repoRootForLog, code: payload.code, error: payload.error, extra: { requestSlotId, query, skip, limit } });
|
|
3432
|
+
res.status(error instanceof OperationTimeoutError ? 504 : 200).json({ available: false, commits: [], hasMore: false, ...payload });
|
|
3433
|
+
}
|
|
3434
|
+
finally {
|
|
3435
|
+
releaseIoSlot(requestSlotId, requestId);
|
|
3436
|
+
}
|
|
3437
|
+
});
|
|
3438
|
+
router.get('/branch-audit', (req, res) => {
|
|
3439
|
+
const workspaceRoot = typeof req.query.workspaceRoot === 'string' ? req.query.workspaceRoot : null;
|
|
3440
|
+
const repoRoot = typeof req.query.repoRoot === 'string' ? req.query.repoRoot : null;
|
|
3441
|
+
const baseRef = typeof req.query.baseRef === 'string' ? req.query.baseRef : null;
|
|
3442
|
+
const branchName = typeof req.query.branchName === 'string' ? req.query.branchName : null;
|
|
3443
|
+
res.json(listBranchAuditRecords({ workspaceRoot, repoRoot, baseRef, branchName }));
|
|
3444
|
+
});
|
|
3445
|
+
router.delete('/branch-audit', (req, res) => {
|
|
3446
|
+
const body = req.body;
|
|
3447
|
+
const ids = Array.isArray(body.ids)
|
|
3448
|
+
? body.ids.filter((id) => typeof id === 'string' && id.length > 0)
|
|
3449
|
+
: undefined;
|
|
3450
|
+
const workspaceRoot = typeof body.workspaceRoot === 'string' ? body.workspaceRoot : null;
|
|
3451
|
+
const repoRoot = typeof body.repoRoot === 'string' ? body.repoRoot : null;
|
|
3452
|
+
const baseRef = typeof body.baseRef === 'string' ? body.baseRef : null;
|
|
3453
|
+
const branchName = typeof body.branchName === 'string' ? body.branchName : null;
|
|
3454
|
+
if ((!ids || ids.length === 0) && !workspaceRoot && !repoRoot && !baseRef && !branchName) {
|
|
3455
|
+
res.status(400).json({ error: 'Expected ids, workspaceRoot, repoRoot, baseRef, or branchName to clear branch audit explanations' });
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
res.json({ ok: true, ...clearBranchAuditRecords({ ids, workspaceRoot, repoRoot, baseRef, branchName }) });
|
|
3459
|
+
});
|
|
3460
|
+
// Mutating Git actions for the right sidebar diff list. Keep this API as a
|
|
3461
|
+
// strict allowlist — never accept arbitrary git arguments from the browser.
|
|
3462
|
+
router.post('/git-action', async (req, res) => {
|
|
3463
|
+
try {
|
|
3464
|
+
const body = req.body;
|
|
3465
|
+
const { action, cwd } = body;
|
|
3466
|
+
if (!cwd) {
|
|
3467
|
+
res.status(400).json({ error: 'Missing cwd', code: 'MISSING_CWD' });
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
if (!action || !['stage-file', 'stage-all', 'unstage-file', 'stash-file', 'stash-all', 'restore-worktree-file', 'commit', 'push', 'pull', 'switch-branch'].includes(action)) {
|
|
3471
|
+
res.status(400).json({ error: 'Unsupported git action', code: 'UNSUPPORTED_ACTION' });
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
3475
|
+
const gitRoot = await findGitRoot(resolvedCwd);
|
|
3476
|
+
if (!gitRoot) {
|
|
3477
|
+
res.status(404).json({ error: 'Not a git repository', code: 'NOT_GIT_REPOSITORY' });
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
const existing = gitActionJobs.get(getGitActionJobKey(gitRoot, action));
|
|
3481
|
+
if (existing?.status === 'running') {
|
|
3482
|
+
res.json({ ok: true, ...serializeGitActionJob(existing) });
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
const job = {
|
|
3486
|
+
id: `git-action-${Date.now().toString(36)}-${(++gitActionJobSeq).toString(36)}`,
|
|
3487
|
+
key: getGitActionJobKey(gitRoot, action),
|
|
3488
|
+
status: 'running',
|
|
3489
|
+
action,
|
|
3490
|
+
cwd: resolvedCwd,
|
|
3491
|
+
gitRoot,
|
|
3492
|
+
startedAt: Date.now(),
|
|
3493
|
+
};
|
|
3494
|
+
gitActionJobs.set(job.key, job);
|
|
3495
|
+
job.promise = (async () => {
|
|
3496
|
+
try {
|
|
3497
|
+
const output = await runGitActionCommand(body, gitRoot);
|
|
3498
|
+
clearGitBundleCacheForRoot(gitRoot);
|
|
3499
|
+
const bundle = await refreshGitBundleCacheDetached(resolvedCwd, gitRoot, true);
|
|
3500
|
+
job.status = 'done';
|
|
3501
|
+
job.output = output;
|
|
3502
|
+
job.message = output.trim() || 'Git action completed';
|
|
3503
|
+
job.bundle = bundle;
|
|
3504
|
+
}
|
|
3505
|
+
catch (error) {
|
|
3506
|
+
job.status = 'error';
|
|
3507
|
+
job.error = error instanceof Error ? error.message : 'Git action failed';
|
|
3508
|
+
job.code = error.code ?? 'GIT_ACTION_FAILED';
|
|
3509
|
+
if (error.confirmationPhrase) {
|
|
3510
|
+
job.message = error.confirmationPhrase;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
finally {
|
|
3514
|
+
job.finishedAt = Date.now();
|
|
3515
|
+
}
|
|
3516
|
+
})();
|
|
3517
|
+
void job.promise;
|
|
3518
|
+
res.json({ ok: true, ...serializeGitActionJob(job) });
|
|
3519
|
+
}
|
|
3520
|
+
catch (error) {
|
|
3521
|
+
const message = error instanceof Error ? error.message : 'Git action failed';
|
|
3522
|
+
const code = error.code ?? 'GIT_ACTION_FAILED';
|
|
3523
|
+
const confirmationPhrase = error.confirmationPhrase;
|
|
3524
|
+
res.status(code === 'CONFIRMATION_REQUIRED' ? 428 : 500).json({ error: message, code, confirmationPhrase });
|
|
3525
|
+
}
|
|
3526
|
+
});
|
|
3527
|
+
router.get('/git-action/status', async (req, res) => {
|
|
3528
|
+
const cwd = typeof req.query.cwd === 'string' ? req.query.cwd : undefined;
|
|
3529
|
+
const action = typeof req.query.action === 'string' ? req.query.action : undefined;
|
|
3530
|
+
const jobId = typeof req.query.jobId === 'string' ? req.query.jobId : undefined;
|
|
3531
|
+
if (jobId) {
|
|
3532
|
+
const job = Array.from(gitActionJobs.values()).find((candidate) => candidate.id === jobId);
|
|
3533
|
+
res.json(job ? { ok: true, ...serializeGitActionJob(job) } : { ok: false, status: 'missing' });
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (!cwd || !action || !['stage-file', 'stage-all', 'unstage-file', 'stash-file', 'stash-all', 'restore-worktree-file', 'commit', 'push', 'pull', 'switch-branch'].includes(action)) {
|
|
3537
|
+
res.status(400).json({ ok: false, error: 'Missing cwd or action' });
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
const resolvedCwd = await pathValidator.validatePathAsync(cwd);
|
|
3541
|
+
const gitRoot = await findGitRoot(resolvedCwd);
|
|
3542
|
+
if (!gitRoot) {
|
|
3543
|
+
res.status(404).json({ ok: false, error: 'Not a git repository' });
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
const job = gitActionJobs.get(getGitActionJobKey(gitRoot, action));
|
|
3547
|
+
res.json(job ? { ok: true, ...serializeGitActionJob(job) } : { ok: false, status: 'missing' });
|
|
3548
|
+
});
|
|
3549
|
+
function sanitizeUploadFilename(filename, fallback) {
|
|
3550
|
+
const normalized = (filename ?? '').replace(/\\/g, '/');
|
|
3551
|
+
const basename = path.basename(normalized).trim();
|
|
3552
|
+
if (!basename || basename === '.' || basename === '..')
|
|
3553
|
+
return fallback;
|
|
3554
|
+
return basename;
|
|
3555
|
+
}
|
|
3556
|
+
function normalizeRemoteAddress(address) {
|
|
3557
|
+
if (!address)
|
|
3558
|
+
return '';
|
|
3559
|
+
return address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address;
|
|
3560
|
+
}
|
|
3561
|
+
function isLocalBrowserRequest(req) {
|
|
3562
|
+
const remoteAddress = normalizeRemoteAddress(req.socket.remoteAddress);
|
|
3563
|
+
if (LOOPBACK_ADDRESSES.has(req.socket.remoteAddress ?? '') || LOOPBACK_ADDRESSES.has(remoteAddress) || remoteAddress.startsWith('127.')) {
|
|
3564
|
+
return true;
|
|
3565
|
+
}
|
|
3566
|
+
return getLanIPv4Addresses().includes(remoteAddress);
|
|
3567
|
+
}
|
|
3568
|
+
function getOpenCommand(targetPath, isDirectory) {
|
|
3569
|
+
if (process.platform === 'darwin') {
|
|
3570
|
+
return isDirectory
|
|
3571
|
+
? { command: 'open', args: [targetPath] }
|
|
3572
|
+
: { command: 'open', args: ['-R', targetPath] };
|
|
3573
|
+
}
|
|
3574
|
+
if (process.platform === 'win32') {
|
|
3575
|
+
return isDirectory
|
|
3576
|
+
? { command: 'explorer.exe', args: [targetPath] }
|
|
3577
|
+
: { command: 'explorer.exe', args: ['/select,', targetPath] };
|
|
3578
|
+
}
|
|
3579
|
+
return isDirectory
|
|
3580
|
+
? { command: 'xdg-open', args: [targetPath] }
|
|
3581
|
+
: { command: 'xdg-open', args: [path.dirname(targetPath)] };
|
|
3582
|
+
}
|
|
3583
|
+
router.get('/local-open-availability', (req, res) => {
|
|
3584
|
+
const available = isLocalBrowserRequest(req);
|
|
3585
|
+
res.json({ available, platform: process.platform });
|
|
3586
|
+
});
|
|
3587
|
+
router.post('/open-in-file-browser', async (req, res) => {
|
|
3588
|
+
try {
|
|
3589
|
+
if (!isLocalBrowserRequest(req)) {
|
|
3590
|
+
res.status(403).json({ error: 'Only available from the Termdock host machine', code: 'NOT_LOCAL_BROWSER' });
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
const requestedPath = typeof req.body?.path === 'string' ? req.body.path : '';
|
|
3594
|
+
if (!requestedPath) {
|
|
3595
|
+
res.status(400).json({ error: 'Missing path', code: 'MISSING_PATH' });
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
const resolvedPath = await pathValidator.validatePathAsync(requestedPath);
|
|
3599
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
3600
|
+
const openCommand = getOpenCommand(resolvedPath, stat.isDirectory());
|
|
3601
|
+
if (!openCommand) {
|
|
3602
|
+
res.status(501).json({ error: 'Opening file browser is not supported on this platform', code: 'UNSUPPORTED_PLATFORM' });
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
const child = spawn(openCommand.command, openCommand.args, {
|
|
3606
|
+
detached: true,
|
|
3607
|
+
stdio: 'ignore',
|
|
3608
|
+
});
|
|
3609
|
+
child.unref();
|
|
3610
|
+
res.json({ ok: true });
|
|
3611
|
+
}
|
|
3612
|
+
catch (error) {
|
|
3613
|
+
if (error instanceof Error && error.code === 'PATH_NOT_ALLOWED') {
|
|
3614
|
+
res.status(403).json({ error: 'Path not allowed', code: 'PATH_NOT_ALLOWED' });
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
const message = error instanceof Error ? error.message : 'Failed to open file browser';
|
|
3618
|
+
res.status(500).json({ error: message, code: 'OPEN_FILE_BROWSER_FAILED' });
|
|
3619
|
+
}
|
|
3620
|
+
});
|
|
3621
|
+
router.post('/upload', async (req, res) => {
|
|
3622
|
+
try {
|
|
3623
|
+
const dir = typeof req.query.dir === 'string' ? req.query.dir : '';
|
|
3624
|
+
if (!dir) {
|
|
3625
|
+
res.status(400).json({ error: 'Missing dir query parameter', code: 'MISSING_DIR' });
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
const resolvedDir = await pathValidator.validatePathAsync(dir);
|
|
3629
|
+
const stat = await fs.promises.stat(resolvedDir);
|
|
3630
|
+
if (!stat.isDirectory()) {
|
|
3631
|
+
res.status(400).json({ error: 'Target is not a directory', code: 'NOT_A_DIRECTORY' });
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
const files = [];
|
|
3635
|
+
let fileCount = 0;
|
|
3636
|
+
let aborted = false;
|
|
3637
|
+
let totalSize = 0;
|
|
3638
|
+
const bb = busboy({
|
|
3639
|
+
headers: req.headers,
|
|
3640
|
+
// Browser FormData sends UTF-8 filenames, while busboy defaults bare
|
|
3641
|
+
// multipart filename parameters to latin1. Without this, dropped files
|
|
3642
|
+
// named with CJK characters turn into mojibake like "æ¥è¯¢".
|
|
3643
|
+
defParamCharset: 'utf8',
|
|
3644
|
+
limits: { fileSize: MAX_UPLOAD_SIZE, files: MAX_UPLOAD_FILES },
|
|
3645
|
+
});
|
|
3646
|
+
const writePromises = [];
|
|
3647
|
+
bb.on('file', (_fieldname, fileStream, info) => {
|
|
3648
|
+
if (aborted) {
|
|
3649
|
+
fileStream.resume();
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
fileCount++;
|
|
3653
|
+
if (fileCount > MAX_UPLOAD_FILES) {
|
|
3654
|
+
aborted = true;
|
|
3655
|
+
fileStream.resume();
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
const { filename } = info;
|
|
3659
|
+
const destName = sanitizeUploadFilename(filename, `file_${fileCount}`);
|
|
3660
|
+
const baseDestPath = path.join(resolvedDir, destName);
|
|
3661
|
+
// Resolve unique path asynchronously inside the write promise
|
|
3662
|
+
const writePromise = (async () => {
|
|
3663
|
+
const destPath = await uniquePath(baseDestPath);
|
|
3664
|
+
return new Promise((resolve, reject) => {
|
|
3665
|
+
const writeStream = fs.createWriteStream(destPath);
|
|
3666
|
+
let fileSize = 0;
|
|
3667
|
+
fileStream.on('data', (chunk) => {
|
|
3668
|
+
fileSize += chunk.length;
|
|
3669
|
+
totalSize += chunk.length;
|
|
3670
|
+
if (totalSize > MAX_UPLOAD_SIZE) {
|
|
3671
|
+
aborted = true;
|
|
3672
|
+
fileStream.destroy?.(new Error('File too large'));
|
|
3673
|
+
writeStream.destroy();
|
|
3674
|
+
return;
|
|
3675
|
+
}
|
|
3676
|
+
});
|
|
3677
|
+
fileStream.pipe(writeStream);
|
|
3678
|
+
writeStream.on('finish', () => {
|
|
3679
|
+
files.push({ name: destName, path: destPath, size: fileSize });
|
|
3680
|
+
resolve();
|
|
3681
|
+
});
|
|
3682
|
+
writeStream.on('error', (err) => {
|
|
3683
|
+
fs.promises.unlink(destPath).catch(() => { });
|
|
3684
|
+
reject(err);
|
|
3685
|
+
});
|
|
3686
|
+
});
|
|
3687
|
+
})();
|
|
3688
|
+
writePromises.push(writePromise);
|
|
3689
|
+
});
|
|
3690
|
+
bb.on('error', (_err) => {
|
|
3691
|
+
// Will be handled by the promise rejection below
|
|
3692
|
+
});
|
|
3693
|
+
bb.on('filesLimit', () => {
|
|
3694
|
+
aborted = true;
|
|
3695
|
+
});
|
|
3696
|
+
bb.on('finish', async () => {
|
|
3697
|
+
try {
|
|
3698
|
+
await Promise.all(writePromises);
|
|
3699
|
+
if (aborted && files.length === 0) {
|
|
3700
|
+
res.status(413).json({ error: 'Upload limit exceeded', code: 'UPLOAD_LIMIT' });
|
|
3701
|
+
return;
|
|
3702
|
+
}
|
|
3703
|
+
res.status(200).json({ files });
|
|
3704
|
+
}
|
|
3705
|
+
catch (err) {
|
|
3706
|
+
const message = err instanceof Error ? err.message : 'Upload failed';
|
|
3707
|
+
res.status(500).json({ error: message, code: 'UPLOAD_FAILED' });
|
|
3708
|
+
}
|
|
3709
|
+
});
|
|
3710
|
+
req.pipe(bb);
|
|
3711
|
+
}
|
|
3712
|
+
catch (error) {
|
|
3713
|
+
if (error instanceof Error && error.code === 'PATH_NOT_ALLOWED') {
|
|
3714
|
+
res.status(403).json({ error: 'Path not allowed', code: 'PATH_NOT_ALLOWED' });
|
|
3715
|
+
return;
|
|
3716
|
+
}
|
|
3717
|
+
const message = error instanceof Error ? error.message : 'Upload failed';
|
|
3718
|
+
res.status(500).json({ error: message, code: 'UPLOAD_FAILED' });
|
|
3719
|
+
}
|
|
3720
|
+
});
|
|
3721
|
+
async function uniquePath(filePath) {
|
|
3722
|
+
try {
|
|
3723
|
+
await fs.promises.access(filePath);
|
|
3724
|
+
// File exists — find a unique name
|
|
3725
|
+
const ext = path.extname(filePath);
|
|
3726
|
+
const base = filePath.slice(0, filePath.length - ext.length);
|
|
3727
|
+
let counter = 1;
|
|
3728
|
+
let candidate;
|
|
3729
|
+
do {
|
|
3730
|
+
candidate = `${base}_${counter}${ext}`;
|
|
3731
|
+
counter++;
|
|
3732
|
+
} while (await exists(candidate));
|
|
3733
|
+
return candidate;
|
|
3734
|
+
}
|
|
3735
|
+
catch {
|
|
3736
|
+
// File doesn't exist — use as-is
|
|
3737
|
+
return filePath;
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
async function exists(p) {
|
|
3741
|
+
try {
|
|
3742
|
+
await fs.promises.access(p);
|
|
3743
|
+
return true;
|
|
3744
|
+
}
|
|
3745
|
+
catch {
|
|
3746
|
+
return false;
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
export default router;
|