aicodeman 1.34.0 → 1.40.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.
Files changed (299) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/attachment-registry.d.ts +6 -0
  4. package/dist/attachment-registry.d.ts.map +1 -1
  5. package/dist/attachment-registry.js +9 -3
  6. package/dist/attachment-registry.js.map +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +2 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config/cli-registry/patterns.d.ts +6 -0
  11. package/dist/config/cli-registry/patterns.d.ts.map +1 -1
  12. package/dist/config/cli-registry/patterns.js +13 -0
  13. package/dist/config/cli-registry/patterns.js.map +1 -1
  14. package/dist/config/cli-registry/schema.d.ts +13 -0
  15. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  16. package/dist/config/cli-registry/schema.js +60 -2
  17. package/dist/config/cli-registry/schema.js.map +1 -1
  18. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  19. package/dist/config/cli-registry/stock.js +175 -7
  20. package/dist/config/cli-registry/stock.js.map +1 -1
  21. package/dist/config/cli-registry/types.d.ts +93 -6
  22. package/dist/config/cli-registry/types.d.ts.map +1 -1
  23. package/dist/config/dependency-registry.d.ts +7 -0
  24. package/dist/config/dependency-registry.d.ts.map +1 -1
  25. package/dist/config/dependency-registry.js +15 -0
  26. package/dist/config/dependency-registry.js.map +1 -1
  27. package/dist/config/path-probe.d.ts +37 -0
  28. package/dist/config/path-probe.d.ts.map +1 -0
  29. package/dist/config/path-probe.js +43 -0
  30. package/dist/config/path-probe.js.map +1 -0
  31. package/dist/deepseek-route-config.d.ts +110 -0
  32. package/dist/deepseek-route-config.d.ts.map +1 -0
  33. package/dist/deepseek-route-config.js +477 -0
  34. package/dist/deepseek-route-config.js.map +1 -0
  35. package/dist/git-workspace-status.d.ts +246 -0
  36. package/dist/git-workspace-status.d.ts.map +1 -0
  37. package/dist/git-workspace-status.js +626 -0
  38. package/dist/git-workspace-status.js.map +1 -0
  39. package/dist/hooks-config.d.ts.map +1 -1
  40. package/dist/hooks-config.js +83 -13
  41. package/dist/hooks-config.js.map +1 -1
  42. package/dist/model-config-resolvers.d.ts +29 -0
  43. package/dist/model-config-resolvers.d.ts.map +1 -0
  44. package/dist/model-config-resolvers.js +34 -0
  45. package/dist/model-config-resolvers.js.map +1 -0
  46. package/dist/session-display-model.d.ts +110 -0
  47. package/dist/session-display-model.d.ts.map +1 -0
  48. package/dist/session-display-model.js +154 -0
  49. package/dist/session-display-model.js.map +1 -0
  50. package/dist/session.d.ts +145 -8
  51. package/dist/session.d.ts.map +1 -1
  52. package/dist/session.js +280 -35
  53. package/dist/session.js.map +1 -1
  54. package/dist/types/api.d.ts +7 -0
  55. package/dist/types/api.d.ts.map +1 -1
  56. package/dist/types/api.js.map +1 -1
  57. package/dist/types/session.d.ts +28 -1
  58. package/dist/types/session.d.ts.map +1 -1
  59. package/dist/types/session.js.map +1 -1
  60. package/dist/types/tools.d.ts +1 -1
  61. package/dist/types/tools.d.ts.map +1 -1
  62. package/dist/utils/bounded-path-probe.d.ts +99 -0
  63. package/dist/utils/bounded-path-probe.d.ts.map +1 -0
  64. package/dist/utils/bounded-path-probe.js +228 -0
  65. package/dist/utils/bounded-path-probe.js.map +1 -0
  66. package/dist/utils/cli-executable-resolver.d.ts +2 -0
  67. package/dist/utils/cli-executable-resolver.d.ts.map +1 -1
  68. package/dist/utils/cli-executable-resolver.js +2 -1
  69. package/dist/utils/cli-executable-resolver.js.map +1 -1
  70. package/dist/utils/deepseek-cli-resolver.d.ts +8 -0
  71. package/dist/utils/deepseek-cli-resolver.d.ts.map +1 -1
  72. package/dist/utils/deepseek-cli-resolver.js +18 -2
  73. package/dist/utils/deepseek-cli-resolver.js.map +1 -1
  74. package/dist/utils/dependency-checker.d.ts +2 -0
  75. package/dist/utils/dependency-checker.d.ts.map +1 -1
  76. package/dist/utils/dependency-checker.js +37 -9
  77. package/dist/utils/dependency-checker.js.map +1 -1
  78. package/dist/utils/index.d.ts +2 -0
  79. package/dist/utils/index.d.ts.map +1 -1
  80. package/dist/utils/index.js +1 -0
  81. package/dist/utils/index.js.map +1 -1
  82. package/dist/web/case-path.d.ts +58 -0
  83. package/dist/web/case-path.d.ts.map +1 -0
  84. package/dist/web/case-path.js +182 -0
  85. package/dist/web/case-path.js.map +1 -0
  86. package/dist/web/launch-defaults.d.ts +22 -0
  87. package/dist/web/launch-defaults.d.ts.map +1 -0
  88. package/dist/web/launch-defaults.js +46 -0
  89. package/dist/web/launch-defaults.js.map +1 -0
  90. package/dist/web/public/admin-ui.js.gz +0 -0
  91. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  92. package/dist/web/public/app.abadc970.js +43 -0
  93. package/dist/web/public/app.abadc970.js.br +0 -0
  94. package/dist/web/public/app.abadc970.js.gz +0 -0
  95. package/dist/web/public/approvals-ui.js.gz +0 -0
  96. package/dist/web/public/{constants.d95fade9.js → constants.c0f8f9de.js} +1009 -127
  97. package/dist/web/public/constants.c0f8f9de.js.br +0 -0
  98. package/dist/web/public/constants.c0f8f9de.js.gz +0 -0
  99. package/dist/web/public/cron-ui.js.gz +0 -0
  100. package/dist/web/public/entrance-animations.js +1 -0
  101. package/dist/web/public/entrance-animations.js.br +0 -0
  102. package/dist/web/public/entrance-animations.js.gz +0 -0
  103. package/dist/web/public/git-status-ui.js +719 -0
  104. package/dist/web/public/git-status-ui.js.br +0 -0
  105. package/dist/web/public/git-status-ui.js.gz +0 -0
  106. package/dist/web/public/home-sessions.js +12 -9
  107. package/dist/web/public/home-sessions.js.br +0 -0
  108. package/dist/web/public/home-sessions.js.gz +0 -0
  109. package/dist/web/public/host-wake-ui.js.gz +0 -0
  110. package/dist/web/public/i18n.a31747a9.js +2 -0
  111. package/dist/web/public/i18n.a31747a9.js.br +0 -0
  112. package/dist/web/public/i18n.a31747a9.js.gz +0 -0
  113. package/dist/web/public/{image-input.a216cf9e.js → image-input.898a538b.js} +18 -8
  114. package/dist/web/public/image-input.898a538b.js.br +0 -0
  115. package/dist/web/public/image-input.898a538b.js.gz +0 -0
  116. package/dist/web/public/index.html +205 -42
  117. package/dist/web/public/index.html.br +0 -0
  118. package/dist/web/public/index.html.gz +0 -0
  119. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  120. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  121. package/dist/web/public/{mobile-handlers.23a85db1.js → mobile-handlers.0ef9c311.js} +20 -0
  122. package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
  123. package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
  124. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  125. package/dist/web/public/mobile-overview.js.gz +0 -0
  126. package/dist/web/public/mobile.111b187d.css +1 -0
  127. package/dist/web/public/mobile.111b187d.css.br +0 -0
  128. package/dist/web/public/mobile.111b187d.css.gz +0 -0
  129. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  130. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  131. package/dist/web/public/{panels-ui.6a5898d9.js → panels-ui.4735dfc2.js} +4 -4
  132. package/dist/web/public/panels-ui.4735dfc2.js.br +0 -0
  133. package/dist/web/public/{panels-ui.6a5898d9.js.gz → panels-ui.4735dfc2.js.gz} +0 -0
  134. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  135. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  136. package/dist/web/public/readmymind-ui.js.gz +0 -0
  137. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  138. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  139. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  140. package/dist/web/public/session-lineage.js +229 -86
  141. package/dist/web/public/session-lineage.js.br +0 -0
  142. package/dist/web/public/session-lineage.js.gz +0 -0
  143. package/dist/web/public/session-ui.f731602c.js +100 -0
  144. package/dist/web/public/session-ui.f731602c.js.br +0 -0
  145. package/dist/web/public/session-ui.f731602c.js.gz +0 -0
  146. package/dist/web/public/settings-ui.a97ab16f.js +97 -0
  147. package/dist/web/public/settings-ui.a97ab16f.js.br +0 -0
  148. package/dist/web/public/settings-ui.a97ab16f.js.gz +0 -0
  149. package/dist/web/public/spreadsheet-preview-worker.js +290 -0
  150. package/dist/web/public/spreadsheet-preview-worker.js.br +0 -0
  151. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  152. package/dist/web/public/spreadsheet-preview.af82fa2f.js +555 -0
  153. package/dist/web/public/spreadsheet-preview.af82fa2f.js.br +0 -0
  154. package/dist/web/public/spreadsheet-preview.af82fa2f.js.gz +0 -0
  155. package/dist/web/public/spreadsheet-xlsx-core.js +1006 -0
  156. package/dist/web/public/spreadsheet-xlsx-core.js.br +0 -0
  157. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  158. package/dist/web/public/styles.8ee12666.css +1 -0
  159. package/dist/web/public/styles.8ee12666.css.br +0 -0
  160. package/dist/web/public/styles.8ee12666.css.gz +0 -0
  161. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  162. package/dist/web/public/sw.js +2 -2
  163. package/dist/web/public/sw.js.br +0 -0
  164. package/dist/web/public/sw.js.gz +0 -0
  165. package/dist/web/public/tab-layout-browser.065fcba4.js +1 -0
  166. package/dist/web/public/tab-layout-browser.065fcba4.js.br +0 -0
  167. package/dist/web/public/tab-layout-browser.065fcba4.js.gz +0 -0
  168. package/dist/web/public/{tab-rail-resize.dcdc5772.js → tab-rail-resize.a5867793.js} +1 -1
  169. package/dist/web/public/tab-rail-resize.a5867793.js.br +0 -0
  170. package/dist/web/public/tab-rail-resize.a5867793.js.gz +0 -0
  171. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js +1 -0
  172. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.br +0 -0
  173. package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
  174. package/dist/web/public/terminal-split.js +146 -649
  175. package/dist/web/public/terminal-split.js.br +0 -0
  176. package/dist/web/public/terminal-split.js.gz +0 -0
  177. package/dist/web/public/terminal-tile.js +1611 -0
  178. package/dist/web/public/terminal-tile.js.br +0 -0
  179. package/dist/web/public/terminal-tile.js.gz +0 -0
  180. package/dist/web/public/terminal-ui.5059b30e.js +2 -0
  181. package/dist/web/public/terminal-ui.5059b30e.js.br +0 -0
  182. package/dist/web/public/terminal-ui.5059b30e.js.gz +0 -0
  183. package/dist/web/public/tile-grid.js +2684 -0
  184. package/dist/web/public/tile-grid.js.br +0 -0
  185. package/dist/web/public/tile-grid.js.gz +0 -0
  186. package/dist/web/public/ultracode-panel.js.gz +0 -0
  187. package/dist/web/public/ultracode-windows.js.gz +0 -0
  188. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  189. package/dist/web/public/vendor/exceljs.min.js +44 -0
  190. package/dist/web/public/vendor/exceljs.min.js.br +0 -0
  191. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  192. package/dist/web/public/vendor/fflate.min.js +1 -0
  193. package/dist/web/public/vendor/fflate.min.js.br +0 -0
  194. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  195. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  196. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  197. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  198. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  199. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  200. package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
  201. package/dist/web/public/vendor/xterm-zerolag-input.bbb5f84c.js.gz +0 -0
  202. package/dist/web/public/vendor/xterm.css.gz +0 -0
  203. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  204. package/dist/web/public/{voice-input.c4b51eb6.js → voice-input.2342c37b.js} +64 -10
  205. package/dist/web/public/voice-input.2342c37b.js.br +0 -0
  206. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  207. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  208. package/dist/web/public/webview-tabs.js +9 -1
  209. package/dist/web/public/webview-tabs.js.br +0 -0
  210. package/dist/web/public/webview-tabs.js.gz +0 -0
  211. package/dist/web/routes/case-routes.d.ts.map +1 -1
  212. package/dist/web/routes/case-routes.js +136 -18
  213. package/dist/web/routes/case-routes.js.map +1 -1
  214. package/dist/web/routes/cli-registry-routes.d.ts.map +1 -1
  215. package/dist/web/routes/cli-registry-routes.js +3 -1
  216. package/dist/web/routes/cli-registry-routes.js.map +1 -1
  217. package/dist/web/routes/doctor-routes.d.ts +23 -0
  218. package/dist/web/routes/doctor-routes.d.ts.map +1 -0
  219. package/dist/web/routes/doctor-routes.js +83 -0
  220. package/dist/web/routes/doctor-routes.js.map +1 -0
  221. package/dist/web/routes/file-routes.d.ts +7 -0
  222. package/dist/web/routes/file-routes.d.ts.map +1 -1
  223. package/dist/web/routes/file-routes.js +34 -4
  224. package/dist/web/routes/file-routes.js.map +1 -1
  225. package/dist/web/routes/git-status-routes.d.ts +16 -0
  226. package/dist/web/routes/git-status-routes.d.ts.map +1 -0
  227. package/dist/web/routes/git-status-routes.js +98 -0
  228. package/dist/web/routes/git-status-routes.js.map +1 -0
  229. package/dist/web/routes/index.d.ts +2 -0
  230. package/dist/web/routes/index.d.ts.map +1 -1
  231. package/dist/web/routes/index.js +2 -0
  232. package/dist/web/routes/index.js.map +1 -1
  233. package/dist/web/routes/session-routes.d.ts +18 -0
  234. package/dist/web/routes/session-routes.d.ts.map +1 -1
  235. package/dist/web/routes/session-routes.js +106 -42
  236. package/dist/web/routes/session-routes.js.map +1 -1
  237. package/dist/web/routes/status-telemetry-routes.d.ts +3 -0
  238. package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
  239. package/dist/web/routes/status-telemetry-routes.js +12 -0
  240. package/dist/web/routes/status-telemetry-routes.js.map +1 -1
  241. package/dist/web/routes/system-routes.d.ts.map +1 -1
  242. package/dist/web/routes/system-routes.js +13 -0
  243. package/dist/web/routes/system-routes.js.map +1 -1
  244. package/dist/web/schemas.d.ts +28 -0
  245. package/dist/web/schemas.d.ts.map +1 -1
  246. package/dist/web/schemas.js +41 -0
  247. package/dist/web/schemas.js.map +1 -1
  248. package/dist/web/server.d.ts +5 -0
  249. package/dist/web/server.d.ts.map +1 -1
  250. package/dist/web/server.js +35 -12
  251. package/dist/web/server.js.map +1 -1
  252. package/dist/web/session-listener-wiring.d.ts +1 -0
  253. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  254. package/dist/web/session-listener-wiring.js +11 -0
  255. package/dist/web/session-listener-wiring.js.map +1 -1
  256. package/package.json +3 -1
  257. package/scripts/postinstall.js +16 -0
  258. package/skills/codeman/reference/verbs.md +3 -0
  259. package/dist/web/public/app.cf0db8ce.js +0 -43
  260. package/dist/web/public/app.cf0db8ce.js.br +0 -0
  261. package/dist/web/public/app.cf0db8ce.js.gz +0 -0
  262. package/dist/web/public/constants.d95fade9.js.br +0 -0
  263. package/dist/web/public/constants.d95fade9.js.gz +0 -0
  264. package/dist/web/public/i18n.398b8331.js +0 -1
  265. package/dist/web/public/i18n.398b8331.js.br +0 -0
  266. package/dist/web/public/i18n.398b8331.js.gz +0 -0
  267. package/dist/web/public/image-input.a216cf9e.js.br +0 -0
  268. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  269. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  270. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  271. package/dist/web/public/mobile.1aa4952d.css +0 -1
  272. package/dist/web/public/mobile.1aa4952d.css.br +0 -0
  273. package/dist/web/public/mobile.1aa4952d.css.gz +0 -0
  274. package/dist/web/public/panels-ui.6a5898d9.js.br +0 -0
  275. package/dist/web/public/session-ui.bf66c13b.js +0 -89
  276. package/dist/web/public/session-ui.bf66c13b.js.br +0 -0
  277. package/dist/web/public/session-ui.bf66c13b.js.gz +0 -0
  278. package/dist/web/public/settings-ui.331d3b1d.js +0 -97
  279. package/dist/web/public/settings-ui.331d3b1d.js.br +0 -0
  280. package/dist/web/public/settings-ui.331d3b1d.js.gz +0 -0
  281. package/dist/web/public/styles.8c485d26.css +0 -1
  282. package/dist/web/public/styles.8c485d26.css.br +0 -0
  283. package/dist/web/public/styles.8c485d26.css.gz +0 -0
  284. package/dist/web/public/tab-layout-browser.4e0ed50c.js +0 -1
  285. package/dist/web/public/tab-layout-browser.4e0ed50c.js.br +0 -0
  286. package/dist/web/public/tab-layout-browser.4e0ed50c.js.gz +0 -0
  287. package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
  288. package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
  289. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js +0 -1
  290. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.br +0 -0
  291. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  292. package/dist/web/public/terminal-ui.a4535311.js +0 -2
  293. package/dist/web/public/terminal-ui.a4535311.js.br +0 -0
  294. package/dist/web/public/terminal-ui.a4535311.js.gz +0 -0
  295. package/dist/web/public/upload.html +0 -155
  296. package/dist/web/public/upload.html.br +0 -0
  297. package/dist/web/public/upload.html.gz +0 -0
  298. package/dist/web/public/voice-input.c4b51eb6.js.br +0 -0
  299. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
@@ -0,0 +1,626 @@
1
+ /**
2
+ * @fileoverview "What has this session's workspace not committed or pushed?": a read-only git
3
+ * snapshot of a session's working directory, for the bottom-bar Git indicator and its panel
4
+ * (`GET /api/sessions/:id/git-status`). Agents leave work uncommitted and unpushed; this makes that
5
+ * visible without leaving Codeman.
6
+ *
7
+ * Split so the parts that matter test without a repo:
8
+ * - pure: `parsePorcelainV2` (status output → branch, upstream, ahead/behind, per-file entries),
9
+ * `parseCommitLog`
10
+ * - IO: `getGitWorkspaceStatus` (a handful of async, bounded, read-only `git` calls), with a short
11
+ * single-flight cache so several tabs polling one repo cost one set of git processes
12
+ *
13
+ * WHICH repositories. `getGitWorkspaceOverview` answers for the session's working directory:
14
+ * - inside a repository (or at its root): that one repository. git finds it by walking UP, so a
15
+ * subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder
16
+ * to the outer one, and is not scanned;
17
+ * - NOT inside one (a folder that holds several projects): every repository found up to two levels
18
+ * DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules`
19
+ * and the like, never following symlinks), each reported separately;
20
+ * - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles
21
+ * repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work.
22
+ *
23
+ * Rules the code keeps and the tests pin:
24
+ * - READ-ONLY and OFFLINE. It never fetches, pulls, commits or writes. "Behind" therefore reflects
25
+ * the last fetch (the UI says so); "ahead" and the unpushed list are exact against the
26
+ * remote-tracking refs already on disk. `--no-optional-locks` keeps `git status` from even
27
+ * refreshing the index, so polling cannot contend with the agent's own git commands.
28
+ * - Every call is async (`execFile`), bounded by a timeout, and never interpolates a path into a
29
+ * shell: the working directory is the process `cwd`, and the only operand-like input is a fixed
30
+ * revision range.
31
+ * - Output is capped: the counts are exact, the lists are not (`filesTruncated`).
32
+ * - git can run helpers a repository configures: a clean filter (`filter.<name>.clean`) still runs
33
+ * during `git status` and `git diff`, as it does for any `git status`. A LOCAL session already
34
+ * runs as this same OS user, so polling adds no privilege there. What is turned off: the
35
+ * filesystem monitor (`core.fsmonitor`), external diff and textconv drivers, and the signature
36
+ * program (`log.showSignature`). A repository a container can write to is NOT inspected: a
37
+ * Docker session answers `unsupported`, and any repository whose root is, or is inside, a Docker
38
+ * case workspace is dropped from the walk-up, the scan below a folder, and the diff route, because
39
+ * the container could have planted that config and git here would run it on the host.
40
+ * - Remote URLs and git's stderr can embed `user:token@host`; anything that reaches a client goes
41
+ * through `redactGitCredentials`.
42
+ *
43
+ * @module git-workspace-status
44
+ */
45
+ import { execFile } from 'node:child_process';
46
+ import { promises as fs } from 'node:fs';
47
+ import { homedir } from 'node:os';
48
+ import { basename, join, relative, sep } from 'node:path';
49
+ import { promisify } from 'node:util';
50
+ import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
51
+ const execFileAsync = promisify(execFile);
52
+ /** How long one git command may run, unless the caller passes `timeoutMs` (a slow network share needs more). */
53
+ export const DEFAULT_GIT_TIMEOUT_MS = 30_000;
54
+ export const MIN_GIT_TIMEOUT_MS = 5_000;
55
+ export const MAX_GIT_TIMEOUT_MS = 120_000;
56
+ /** `git status` on a huge tree can print a lot; a bound on what we will hold. */
57
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
58
+ /** Max file rows returned. The counts stay exact. */
59
+ export const MAX_FILES = 300;
60
+ /** Max unpushed commits listed. The count stays exact. */
61
+ export const MAX_COMMITS = 50;
62
+ /** A fresh-enough result is reused, so N tabs on one repo cost one set of git calls. */
63
+ const CACHE_TTL_MS = 4000;
64
+ const CACHE_MAX_ENTRIES = 64;
65
+ const EMPTY = {
66
+ branch: null,
67
+ detached: false,
68
+ upstream: null,
69
+ upstreamGone: false,
70
+ ahead: 0,
71
+ behind: 0,
72
+ hasRemote: false,
73
+ counts: { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 },
74
+ files: [],
75
+ filesTruncated: false,
76
+ unpushedCount: 0,
77
+ unpushed: [],
78
+ };
79
+ export const emptyStatus = (state, extra = {}) => ({ ...EMPTY, counts: { ...EMPTY.counts }, state, checkedAt: Date.now(), ...extra });
80
+ /**
81
+ * Parse `git status --porcelain=v2 --branch -z`. Entries are NUL-separated and paths are NOT quoted,
82
+ * so a name with spaces, quotes or a newline arrives intact. A rename/copy (`2 ...`) is followed by
83
+ * one more NUL-terminated token holding the original path.
84
+ */
85
+ export function parsePorcelainV2(text) {
86
+ const out = {
87
+ branch: null,
88
+ detached: false,
89
+ upstream: null,
90
+ upstreamGone: false,
91
+ ahead: 0,
92
+ behind: 0,
93
+ files: [],
94
+ };
95
+ let sawAb = false;
96
+ const tokens = text.split('\0');
97
+ for (let i = 0; i < tokens.length; i++) {
98
+ const t = tokens[i];
99
+ if (!t)
100
+ continue;
101
+ if (t.startsWith('# ')) {
102
+ const [key, ...rest] = t.slice(2).split(' ');
103
+ const value = rest.join(' ');
104
+ if (key === 'branch.head') {
105
+ out.detached = value === '(detached)';
106
+ out.branch = out.detached ? null : value;
107
+ }
108
+ else if (key === 'branch.upstream') {
109
+ out.upstream = value;
110
+ }
111
+ else if (key === 'branch.ab') {
112
+ sawAb = true;
113
+ const m = /^\+(\d+) -(\d+)$/.exec(value);
114
+ if (m) {
115
+ out.ahead = Number(m[1]);
116
+ out.behind = Number(m[2]);
117
+ }
118
+ }
119
+ continue;
120
+ }
121
+ const type = t[0];
122
+ if (type === '1') {
123
+ // 1 XY sub mH mI mW hH hI path
124
+ const f = t.split(' ');
125
+ const xy = f[1] ?? '..';
126
+ out.files.push(...entriesFor(xy, f.slice(8).join(' ')));
127
+ }
128
+ else if (type === '2') {
129
+ // 2 XY sub mH mI mW hH hI Xscore path <NUL> origPath
130
+ const f = t.split(' ');
131
+ const xy = f[1] ?? '..';
132
+ const path = f.slice(9).join(' ');
133
+ const origPath = tokens[++i] ?? '';
134
+ out.files.push(...entriesFor(xy, path, origPath));
135
+ }
136
+ else if (type === 'u') {
137
+ // u XY sub m1 m2 m3 mW h1 h2 h3 path
138
+ const f = t.split(' ');
139
+ out.files.push({
140
+ path: f.slice(10).join(' '),
141
+ index: f[1]?.[0] ?? 'U',
142
+ worktree: f[1]?.[1] ?? 'U',
143
+ kind: 'conflicted',
144
+ });
145
+ }
146
+ else if (type === '?') {
147
+ out.files.push({ path: t.slice(2), index: '?', worktree: '?', kind: 'untracked' });
148
+ }
149
+ // '!' (ignored) is not requested; anything unknown is skipped rather than guessed at.
150
+ }
151
+ out.upstreamGone = out.upstream !== null && !sawAb;
152
+ return out;
153
+ }
154
+ /** One porcelain entry can be both staged AND modified in the tree: that is two rows, one per kind. */
155
+ function entriesFor(xy, path, origPath) {
156
+ const index = xy[0] ?? '.';
157
+ const worktree = xy[1] ?? '.';
158
+ const rows = [];
159
+ const base = origPath ? { path, origPath } : { path };
160
+ if (index !== '.')
161
+ rows.push({ ...base, index, worktree, kind: 'staged' });
162
+ if (worktree !== '.')
163
+ rows.push({ ...base, index, worktree, kind: 'unstaged' });
164
+ return rows;
165
+ }
166
+ /** Parse `git log --format=%h%x1f%an%x1f%ct%x1f%s%x1e`. */
167
+ export function parseCommitLog(text) {
168
+ const out = [];
169
+ for (const record of text.split('\x1e')) {
170
+ const r = record.replace(/^\n+/, '');
171
+ if (!r)
172
+ continue;
173
+ const [hash, author, time, ...subject] = r.split('\x1f');
174
+ if (!hash)
175
+ continue;
176
+ out.push({ hash, author: author ?? '', time: Number(time) || 0, subject: subject.join('\x1f') });
177
+ }
178
+ return out;
179
+ }
180
+ export const runGit = async (cwd, args, opts) => {
181
+ const { stdout } = await execFileAsync('git',
182
+ // --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
183
+ // consult a filesystem monitor on behalf of a poll. log.showSignature=false: `git log` must not run
184
+ // a configured gpg.program to verify signatures.
185
+ ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', ...args], {
186
+ cwd,
187
+ timeout: opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
188
+ maxBuffer: MAX_OUTPUT_BYTES,
189
+ env: { ...gitNonInteractiveEnv(), LC_ALL: 'C', LANG: 'C', GIT_OPTIONAL_LOCKS: '0' },
190
+ });
191
+ return stdout;
192
+ };
193
+ function describeFailure(err) {
194
+ const e = err;
195
+ const stderr = typeof e.stderr === 'string' ? e.stderr : '';
196
+ if (/not a git repository/i.test(stderr))
197
+ return { notARepo: true, message: '' };
198
+ if (e.code === 'ENOENT')
199
+ return { notARepo: false, message: 'git is not installed (or the folder no longer exists)' };
200
+ if (e.code === 'ETIMEDOUT' || err.killed)
201
+ return { notARepo: false, message: 'git timed out' };
202
+ const text = (stderr || e.message || 'git failed').trim().split('\n')[0];
203
+ return { notARepo: false, message: redactGitCredentials(text).slice(0, 300) };
204
+ }
205
+ async function collect(cwd, git, timeoutMs) {
206
+ let statusText;
207
+ try {
208
+ statusText = await git(cwd, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=normal', '--ignore-submodules=dirty'], { timeoutMs });
209
+ }
210
+ catch (err) {
211
+ const f = describeFailure(err);
212
+ return f.notARepo ? emptyStatus('not-a-repo') : emptyStatus('error', { error: f.message });
213
+ }
214
+ const parsed = parsePorcelainV2(statusText);
215
+ const safe = async (args) => {
216
+ try {
217
+ return await git(cwd, args, { timeoutMs });
218
+ }
219
+ catch {
220
+ return '';
221
+ }
222
+ };
223
+ // A configured upstream whose remote branch is gone has no `branch.ab`, and `@{upstream}` no longer
224
+ // resolves: treat it as no usable upstream rather than letting the failed rev-list read as 0.
225
+ const hasUpstream = parsed.upstream !== null && !parsed.upstreamGone;
226
+ // With an upstream: what is ahead of it. Without one (a branch never pushed, a detached HEAD, or an
227
+ // upstream that is gone): what is on HEAD but on no remote-tracking ref at all.
228
+ const range = hasUpstream ? ['@{upstream}..HEAD'] : ['HEAD', '--not', '--remotes'];
229
+ const [root, remotes, stash, countText, logText] = await Promise.all([
230
+ safe(['rev-parse', '--show-toplevel']),
231
+ safe(['remote']),
232
+ safe(['stash', 'list', '--format=%gd']),
233
+ safe(['rev-list', '--count', ...range]),
234
+ safe(['log', `--max-count=${MAX_COMMITS}`, '--format=%h%x1f%an%x1f%ct%x1f%s%x1e', ...range]),
235
+ ]);
236
+ const hasRemote = remotes.trim().length > 0;
237
+ // A repository with no remote has nothing to push to, so "unpushed" would be every commit it has.
238
+ const unpushedCount = hasUpstream || hasRemote ? Number(countText.trim()) || 0 : 0;
239
+ const unpushed = unpushedCount > 0 ? parseCommitLog(logText) : [];
240
+ const counts = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, uncommitted: 0, stashes: 0 };
241
+ const distinct = new Set();
242
+ for (const f of parsed.files) {
243
+ counts[f.kind]++;
244
+ distinct.add(f.path);
245
+ }
246
+ counts.uncommitted = distinct.size;
247
+ counts.stashes = stash.split('\n').filter(Boolean).length;
248
+ return {
249
+ state: 'ok',
250
+ repoRoot: root.trim() || undefined,
251
+ branch: parsed.branch,
252
+ detached: parsed.detached,
253
+ upstream: parsed.upstream,
254
+ upstreamGone: parsed.upstreamGone,
255
+ ahead: parsed.ahead,
256
+ behind: parsed.behind,
257
+ hasRemote,
258
+ counts,
259
+ files: parsed.files.slice(0, MAX_FILES),
260
+ filesTruncated: parsed.files.length > MAX_FILES,
261
+ unpushedCount,
262
+ unpushed,
263
+ checkedAt: Date.now(),
264
+ };
265
+ }
266
+ const cache = new Map();
267
+ /** For tests. */
268
+ export function clearGitStatusCache() {
269
+ cache.clear();
270
+ toplevelCache.clear();
271
+ discoveryCache.clear();
272
+ }
273
+ /**
274
+ * `compute()` for `key`, single-flight and briefly cached: concurrent callers share the computation in
275
+ * flight, and a result younger than `CACHE_TTL_MS` is reused. `fresh` skips the reuse (a person pressed
276
+ * Refresh and expects the truth) but still joins a computation that is already running, which is as
277
+ * current as a new one would be.
278
+ */
279
+ async function singleFlight(map, key, opts, compute) {
280
+ const hit = map.get(key);
281
+ if (hit?.inflight)
282
+ return hit.inflight;
283
+ if (!opts.fresh && hit?.value !== undefined && opts.now() - hit.at < CACHE_TTL_MS)
284
+ return hit.value;
285
+ const inflight = compute();
286
+ map.set(key, { at: opts.now(), inflight });
287
+ try {
288
+ const value = await inflight;
289
+ map.set(key, { at: opts.now(), value });
290
+ if (map.size > CACHE_MAX_ENTRIES) {
291
+ for (const [k, v] of map) {
292
+ if (map.size <= CACHE_MAX_ENTRIES)
293
+ break;
294
+ if (k !== key && !v.inflight)
295
+ map.delete(k);
296
+ }
297
+ }
298
+ return value;
299
+ }
300
+ catch (err) {
301
+ map.delete(key);
302
+ throw err;
303
+ }
304
+ }
305
+ /**
306
+ * The git snapshot of `cwd`. Concurrent callers share one in-flight computation, and a result younger
307
+ * than a few seconds is reused, so several tabs polling one repo cost one set of git processes.
308
+ * `fresh` skips the reuse but still joins a computation already running (see `singleFlight`).
309
+ */
310
+ export async function getGitWorkspaceStatus(cwd, opts = {}) {
311
+ const git = opts.git ?? runGit;
312
+ return singleFlight(cache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, () => collect(cwd, git, opts.timeoutMs));
313
+ }
314
+ const toplevelCache = new Map();
315
+ /** The root of the repository enclosing `cwd` (git walks up), from one cheap `rev-parse`. Cached like the status. */
316
+ function enclosingRepoRoot(cwd, opts) {
317
+ const git = opts.git ?? runGit;
318
+ return singleFlight(toplevelCache, cwd, { now: opts.now ?? Date.now, fresh: opts.fresh }, async () => {
319
+ try {
320
+ const root = (await git(cwd, ['rev-parse', '--show-toplevel'], { timeoutMs: opts.timeoutMs })).trim();
321
+ return root ? { state: 'ok', root } : { state: 'not-a-repo' };
322
+ }
323
+ catch (err) {
324
+ const f = describeFailure(err);
325
+ return f.notARepo ? { state: 'not-a-repo' } : { state: 'error', error: f.message };
326
+ }
327
+ });
328
+ }
329
+ // ---------------------------------------------------------------------------
330
+ // Which repositories: the overview
331
+ // ---------------------------------------------------------------------------
332
+ /** How far below the working directory to look for repositories (`cwd/a/b` is found, `cwd/a/b/c` is not). */
333
+ const DISCOVERY_MAX_DEPTH = 2;
334
+ /** Directory entries inspected per folder (after sorting), so a folder with thousands of children stays cheap. */
335
+ const DISCOVERY_MAX_ENTRIES = 300;
336
+ /** Repositories reported for one workspace. */
337
+ export const MAX_REPOS = 12;
338
+ /** The most repositories a caller may ask for: each one costs several git processes per poll. */
339
+ export const MAX_REPOS_LIMIT = 50;
340
+ /** `value` as a whole number within [min, max], else `fallback`. For options that arrive as untrusted query strings. */
341
+ export function clampInt(value, min, max, fallback) {
342
+ // An empty string is "not given", not 0 (Number('') is 0, which would clamp to the minimum).
343
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
344
+ if (!Number.isFinite(n))
345
+ return fallback;
346
+ return Math.min(max, Math.max(min, Math.trunc(n)));
347
+ }
348
+ /** The list of repositories under a folder changes rarely, so it is re-scanned far less often than status. */
349
+ const DISCOVERY_TTL_MS = 30_000;
350
+ /** Folders that are never worth descending into when looking for projects. */
351
+ const DISCOVERY_SKIP = new Set(['node_modules', 'dist', 'build', 'target', '__pycache__', 'venv', 'vendor']);
352
+ /** Status calls in flight at once for one overview: each is several git processes. */
353
+ const STATUS_CONCURRENCY = 4;
354
+ export const emptyOverview = (state, extra = {}) => ({ state, repos: [], reposTruncated: false, checkedAt: Date.now(), ...extra });
355
+ const realOr = async (p) => {
356
+ try {
357
+ return await fs.realpath(p);
358
+ }
359
+ catch {
360
+ return p;
361
+ }
362
+ };
363
+ /** Real paths of `dirs` (a Docker case workspace may be reached through a symlink). */
364
+ const realAll = (dirs) => Promise.all(dirs.map(realOr));
365
+ const isWithin = (child, root) => child === root || child.startsWith(root + sep);
366
+ /**
367
+ * True when `path` is, or is inside, any of the (already real) `roots`. Used for Docker case
368
+ * workspaces: a container can write there, so git must not run on its behalf on the host.
369
+ */
370
+ export async function isInsideAny(path, realRoots) {
371
+ if (!realRoots.length)
372
+ return false;
373
+ const real = await realOr(path);
374
+ return realRoots.some((r) => isWithin(real, r));
375
+ }
376
+ /**
377
+ * True when `repoRoot` is a repository that merely contains the workspace and is the home folder or
378
+ * above it (`$HOME` managed as a dotfiles repo, `/`, `/home`): its changes are not the session's work.
379
+ * A workspace that IS the repository root is never "unrelated", even when that root is the home folder.
380
+ */
381
+ export async function isUnrelatedAncestor(repoRoot, cwd, home) {
382
+ const [root, here, h] = await Promise.all([realOr(repoRoot), realOr(cwd), realOr(home)]);
383
+ if (root === here)
384
+ return false;
385
+ return root === sep || h === root || h.startsWith(root + sep);
386
+ }
387
+ async function hasDotGit(dir) {
388
+ try {
389
+ await fs.lstat(join(dir, '.git')); // a directory, or a file (worktrees and submodules)
390
+ return true;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ /** Most directory entries READ from one folder before sorting and slicing, so the scan of a huge folder is bounded. */
397
+ const DISCOVERY_MAX_SCAN = 5000;
398
+ /** Up to `DISCOVERY_MAX_SCAN` entries of `dir` (null when unreadable). */
399
+ async function readDirBounded(dir) {
400
+ let handle;
401
+ try {
402
+ handle = await fs.opendir(dir);
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ const out = [];
408
+ try {
409
+ for await (const e of handle) {
410
+ out.push(e);
411
+ if (out.length >= DISCOVERY_MAX_SCAN)
412
+ break;
413
+ }
414
+ }
415
+ catch {
416
+ /* a folder that fails mid-read: use what was read */
417
+ }
418
+ finally {
419
+ await handle.close().catch(() => { });
420
+ }
421
+ return out;
422
+ }
423
+ /** Repositories up to `DISCOVERY_MAX_DEPTH` levels below `cwd`, nearest and alphabetical first. Never follows symlinks. */
424
+ export async function discoverChildRepos(cwd, excludeRealRoots = [], maxRepos = MAX_REPOS) {
425
+ const found = [];
426
+ let level = [cwd];
427
+ for (let depth = 1; depth <= DISCOVERY_MAX_DEPTH && level.length > 0; depth++) {
428
+ const next = [];
429
+ for (const dir of level) {
430
+ const entries = await readDirBounded(dir);
431
+ if (!entries)
432
+ continue;
433
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
434
+ entries.length = Math.min(entries.length, DISCOVERY_MAX_ENTRIES);
435
+ for (const e of entries) {
436
+ // isDirectory() is false for a symlink, which is how a link to elsewhere is never followed.
437
+ if (!e.isDirectory() || e.name.startsWith('.') || DISCOVERY_SKIP.has(e.name))
438
+ continue;
439
+ const child = join(dir, e.name);
440
+ // A Docker case workspace (or anything inside one) is never inspected, nor descended into.
441
+ if (await isInsideAny(child, excludeRealRoots))
442
+ continue;
443
+ if (await hasDotGit(child))
444
+ found.push(child);
445
+ else
446
+ next.push(child);
447
+ }
448
+ }
449
+ level = next;
450
+ }
451
+ return { dirs: found.slice(0, maxRepos), truncated: found.length > maxRepos };
452
+ }
453
+ const discoveryCache = new Map();
454
+ /** Run `fn` over `items` with at most `limit` in flight, keeping the input order. */
455
+ async function mapLimited(items, limit, fn) {
456
+ const out = new Array(items.length);
457
+ let next = 0;
458
+ const worker = async () => {
459
+ while (next < items.length) {
460
+ const i = next++;
461
+ out[i] = await fn(items[i]);
462
+ }
463
+ };
464
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
465
+ return out;
466
+ }
467
+ /** The repository limit and git timeout an overview was computed with, from untrusted options. */
468
+ export function resolveOverviewLimits(opts) {
469
+ return {
470
+ maxRepos: clampInt(opts.maxRepos, 1, MAX_REPOS_LIMIT, MAX_REPOS),
471
+ timeoutMs: clampInt(opts.timeoutMs, MIN_GIT_TIMEOUT_MS, MAX_GIT_TIMEOUT_MS, DEFAULT_GIT_TIMEOUT_MS),
472
+ };
473
+ }
474
+ /**
475
+ * WHICH repositories belong to the workspace (the module header has the rules), without a full
476
+ * status of any of them: one cached `rev-parse` for the enclosing repository, else the cached scan
477
+ * below the folder. The overview and the diff route both go through here, so they cannot disagree.
478
+ */
479
+ async function resolveWorkspaceRepos(cwd, opts) {
480
+ const now = opts.now ?? Date.now;
481
+ const { maxRepos, timeoutMs } = resolveOverviewLimits(opts);
482
+ const dockerRoots = await realAll(opts.dockerWorkspaces ?? []);
483
+ // Checked BEFORE any git runs: git walks up from cwd, and a repository the container can write to
484
+ // could carry config (a clean filter) that runs on the host.
485
+ if (await isInsideAny(cwd, dockerRoots))
486
+ return { kind: 'docker' };
487
+ // The enclosing repository is identified before its full status runs, so an unrelated one above the
488
+ // workspace (a dotfiles repo in $HOME) costs one rev-parse, and its status failing cannot hide the
489
+ // repositories below.
490
+ const top = await enclosingRepoRoot(cwd, { ...opts, timeoutMs });
491
+ if (top.state === 'error')
492
+ return { kind: 'error', error: top.error };
493
+ if (top.state === 'ok') {
494
+ if (await isInsideAny(top.root, dockerRoots))
495
+ return { kind: 'docker' };
496
+ if (!(await isUnrelatedAncestor(top.root, cwd, opts.home ?? homedir())))
497
+ return { kind: 'enclosing', root: top.root };
498
+ }
499
+ // Not inside a repository of this workspace: look below for projects.
500
+ // Keyed by the limit too: a list cut at 12 must not answer a request for 30.
501
+ const discoveryKey = `${cwd}\0${maxRepos}`;
502
+ const hit = discoveryCache.get(discoveryKey);
503
+ let found;
504
+ if (!opts.fresh && hit && now() - hit.at < DISCOVERY_TTL_MS)
505
+ found = hit.value;
506
+ else {
507
+ found = await discoverChildRepos(cwd, dockerRoots, maxRepos);
508
+ discoveryCache.set(discoveryKey, { at: now(), value: found });
509
+ if (discoveryCache.size > CACHE_MAX_ENTRIES)
510
+ discoveryCache.delete(discoveryCache.keys().next().value);
511
+ }
512
+ // The cached list can predate a Docker case linked since: filter it against the roots as they are NOW.
513
+ const dirs = [];
514
+ for (const dir of found.dirs)
515
+ if (!(await isInsideAny(dir, dockerRoots)))
516
+ dirs.push(dir);
517
+ return { kind: 'children', dirs, truncated: found.truncated, limit: maxRepos };
518
+ }
519
+ /**
520
+ * Everything git knows about the session's workspace: the enclosing repository when there is one,
521
+ * otherwise each repository found below the working directory. See the module header for the rules.
522
+ */
523
+ export async function getGitWorkspaceOverview(cwd, opts = {}) {
524
+ const where = await resolveWorkspaceRepos(cwd, opts);
525
+ if (where.kind === 'docker')
526
+ return emptyOverview('unsupported', { reason: 'docker' });
527
+ if (where.kind === 'error')
528
+ return emptyOverview('error', { error: where.error });
529
+ if (where.kind === 'enclosing') {
530
+ const primary = await getGitWorkspaceStatus(cwd, { ...opts, timeoutMs: resolveOverviewLimits(opts).timeoutMs });
531
+ if (primary.state === 'error')
532
+ return emptyOverview('error', { error: primary.error });
533
+ if (primary.state !== 'ok')
534
+ return emptyOverview('not-a-repo');
535
+ const root = primary.repoRoot ?? where.root;
536
+ return {
537
+ state: 'ok',
538
+ repos: [{ name: basename(root), path: relative(cwd, root) || '.', status: primary }],
539
+ reposTruncated: false,
540
+ checkedAt: primary.checkedAt,
541
+ };
542
+ }
543
+ const timeoutMs = resolveOverviewLimits(opts).timeoutMs;
544
+ const statuses = await mapLimited(where.dirs, STATUS_CONCURRENCY, (dir) => getGitWorkspaceStatus(dir, { ...opts, timeoutMs }));
545
+ const repos = [];
546
+ where.dirs.forEach((dir, i) => {
547
+ const status = statuses[i];
548
+ // A repository git could not read (a timeout on a slow share, a broken worktree) stays in the
549
+ // list with its error, so it is visible that something is not being reported; only a folder
550
+ // that turned out not to be a repository after all is left out.
551
+ if (status.state === 'ok' || status.state === 'error')
552
+ repos.push({ name: basename(dir), path: relative(cwd, dir), status });
553
+ });
554
+ if (!repos.length)
555
+ return emptyOverview('not-a-repo');
556
+ return { state: 'ok', repos, reposTruncated: where.truncated, repoLimit: where.limit, checkedAt: Date.now() };
557
+ }
558
+ /**
559
+ * `repo` when it is the root of one of the repositories the overview reports for `cwd` (the same rules
560
+ * and caches, and the Docker roots as they are now), else null. The diff route checks a requested
561
+ * repository with this rather than recomputing every repository's status.
562
+ */
563
+ export async function findWorkspaceRepo(cwd, repo, opts = {}) {
564
+ const where = await resolveWorkspaceRepos(cwd, opts);
565
+ const roots = where.kind === 'enclosing' ? [where.root] : where.kind === 'children' ? where.dirs : [];
566
+ // git reports a repository root with symlinks resolved; a discovered folder may be reached through one.
567
+ for (const root of roots)
568
+ if (root === repo || (await realOr(root)) === repo)
569
+ return repo;
570
+ return null;
571
+ }
572
+ // ── Per-file diff ──────────────────────────────────────────────────────────
573
+ /** Longest diff handed to the browser; beyond this it is cut at a line boundary and flagged. */
574
+ export const MAX_DIFF_BYTES = 400 * 1024;
575
+ /** A repo-relative path git reported, minus anything that could escape the repo. (A leading `-` is fine: every operand follows `--`.) */
576
+ export function isSafeRepoRelativePath(p) {
577
+ if (!p || p.length > 4096 || p.includes('\0') || p.startsWith('/'))
578
+ return false;
579
+ return !p.split('/').includes('..');
580
+ }
581
+ /**
582
+ * The diff of one changed file, as the panel's rows describe it: `staged` is index vs HEAD,
583
+ * `unstaged`/`conflicted` is working tree vs index (a conflict shows git's combined diff), and
584
+ * `untracked` is the whole file as additions. Read-only. `--no-ext-diff --no-textconv` stop the external
585
+ * diff and textconv drivers a repository configures; a clean filter still runs, as it does for any
586
+ * `git diff`, which is why a container-writable repository never reaches this function.
587
+ */
588
+ export async function getGitFileDiff(repoRoot, file, opts = {}) {
589
+ if (!isSafeRepoRelativePath(file.path) || (file.origPath && !isSafeRepoRelativePath(file.origPath))) {
590
+ throw new Error('Invalid path');
591
+ }
592
+ const git = opts.git ?? runGit;
593
+ const base = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '-U3'];
594
+ let args;
595
+ if (file.kind === 'untracked')
596
+ args = [...base, '--no-index', '--', '/dev/null', file.path];
597
+ else {
598
+ const paths = file.origPath ? [file.origPath, file.path] : [file.path];
599
+ args = file.kind === 'staged' ? [...base, '--cached', '-M', '--', ...paths] : [...base, '--', ...paths];
600
+ }
601
+ let out;
602
+ let cutShort = false;
603
+ try {
604
+ out = await git(repoRoot, args, { timeoutMs: opts.timeoutMs });
605
+ }
606
+ catch (err) {
607
+ const e = err;
608
+ // `--no-index` exits 1 when the files differ, which is the normal case for it.
609
+ if (file.kind === 'untracked' && e.code === 1 && typeof e.stdout === 'string')
610
+ out = e.stdout;
611
+ // A diff past runGit's output bound: git was stopped, and what it printed so far is cut below like
612
+ // any oversized diff.
613
+ else if (e.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' && typeof e.stdout === 'string') {
614
+ out = e.stdout;
615
+ cutShort = true;
616
+ }
617
+ else
618
+ throw err;
619
+ }
620
+ const binary = /^Binary files .* differ$/m.test(out) || /^GIT binary patch$/m.test(out);
621
+ if (out.length <= MAX_DIFF_BYTES)
622
+ return { diff: out, truncated: cutShort, binary };
623
+ const cut = out.lastIndexOf('\n', MAX_DIFF_BYTES);
624
+ return { diff: out.slice(0, cut > 0 ? cut : MAX_DIFF_BYTES), truncated: true, binary };
625
+ }
626
+ //# sourceMappingURL=git-workspace-status.js.map