@bevel-software/platform-core-backend 0.29.0 → 0.31.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 (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -0,0 +1,811 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { logger } from '../../shared/logging.js';
4
+ import { isAbsence } from '../../shared/fs.contract.js';
5
+ import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
6
+ import { fetchInstant } from './fetch-instant.js';
7
+ import { readGitfile } from '../kb-fs/index.js';
8
+ import { cloneCredentialConfigArgs, cloneTrackingConfigArgs, ORIGIN_FETCH_REFSPEC, SAFE_IMPLICIT_FETCH_ARGS, } from '../kb-fs/clone-config.js';
9
+ const log = logger('kb-repository');
10
+ /**
11
+ * ONE repository per knowledge base, and every branch workspace a worktree of
12
+ * it.
13
+ *
14
+ * A branch workspace used to be a full clone: every object of the history
15
+ * once per branch, and a fetch per branch. A workspace is only ever checked
16
+ * out on its own branch and never switches, so almost all of that was the
17
+ * same history copied over and over (142 branches, 60 GB, on 2026-10-14).
18
+ * Git's own answer to "many checkouts, one history" is a worktree: this
19
+ * repository holds the objects, the remote-tracking refs and each branch's
20
+ * local ref, and `<workspacesRoot>/<branch id>/<kb folder>` — the path a
21
+ * clone had — is a worktree of it. Its `.git` is a FILE pointing here.
22
+ *
23
+ * Bare, so no branch is checked out in the repository itself (a non-bare
24
+ * clone would hold the default branch, which is one of the workspaces), and
25
+ * with no local branches at all until a worktree adds one: the remote's
26
+ * branches arrive under `refs/remotes/origin/*`, exactly where a clone keeps
27
+ * them, so `origin/<branch>` reads the same from a worktree as from a clone.
28
+ *
29
+ * It lives OUTSIDE the workspaces root, so the orphan sweep (which removes
30
+ * every folder there that is not a known branch's) never sees it.
31
+ *
32
+ * Git was built for many worktrees of one repository and protects what they
33
+ * share with its own locks: one per ref while it is written, one for
34
+ * `packed-refs` (which git waits for, `core.packedRefsTimeout`), one per
35
+ * worktree's index, objects written to a temp file and renamed into place,
36
+ * and a worktree being added marked locked until it is. So git operations
37
+ * in two worktrees run side by side here, as two developers' worktrees do,
38
+ * under nothing but the per-workspace mutex the git layer always held.
39
+ *
40
+ * What git does NOT wait for is its one config file — a second writer is
41
+ * refused, not queued — and what nothing in git serializes is the making of
42
+ * the repository and the moving of its folders. Those three run as the
43
+ * repository's one administrator, {@link exclusive}: the only lock this
44
+ * class adds.
45
+ *
46
+ * Automatic garbage collection is off (`gc.auto 0`): a commit in one
47
+ * worktree would otherwise collect beside every other worktree's work. The
48
+ * startup phase runs {@link maintain} once per boot instead, when nothing
49
+ * is served.
50
+ */
51
+ /** A first fetch of a large knowledge base takes minutes; the floor under its deadline. */
52
+ const FIRST_FETCH_TIMEOUT_MS = 600_000;
53
+ /**
54
+ * Git waits this long for another process's `packed-refs.lock` before failing
55
+ * (the default is one second). A fetch that prunes and a branch delete in
56
+ * another worktree both rewrite `packed-refs`, which is now one file for every
57
+ * workspace.
58
+ */
59
+ const PACKED_REFS_TIMEOUT_MS = '10000';
60
+ /** Written into the repository last, once its first fetch has landed — see `exists`. */
61
+ const READY_MARKER = 'hexis-repository-ready';
62
+ /** Where a converted clone's stash and other local refs are kept, per workspace (see `importLocalRefs`). */
63
+ export const CONVERTED_REFS = 'refs/kb-converted';
64
+ /** Fallback committer identity; every workflow commit overrides it with `--author`. */
65
+ const BOT_NAME = 'Bevel Workflow';
66
+ const BOT_EMAIL = 'bevel-workflow@bevel.software';
67
+ /**
68
+ * Where the repository is: `<root>/<kb folder>.git`, under `override` when the
69
+ * composition root names one (a folder on a persistent volume beside the
70
+ * workspaces root), else a folder next to the workspaces root named after it.
71
+ *
72
+ * The fallback is derived from the root's own name, so two workspaces roots
73
+ * that share a parent (every suite's temporary roots) never share a repository.
74
+ */
75
+ export function repositoryDirFor(workspacesRoot, kbDirName, override) {
76
+ const root = override ?? `${path.resolve(workspacesRoot)}.repository`;
77
+ return path.join(root, `${kbDirName}.git`);
78
+ }
79
+ export async function workingCopyShape(checkoutDir) {
80
+ const dotGit = path.join(checkoutDir, '.git');
81
+ let stat;
82
+ try {
83
+ stat = await fs.stat(dotGit);
84
+ }
85
+ catch (err) {
86
+ if (isAbsence(err))
87
+ return 'none';
88
+ throw err;
89
+ }
90
+ if (stat.isDirectory())
91
+ return 'clone';
92
+ const gitdir = await readGitfile(dotGit);
93
+ if (gitdir === null)
94
+ return 'broken';
95
+ let held;
96
+ try {
97
+ held = await fs.stat(gitdir);
98
+ }
99
+ catch (err) {
100
+ // Only absence shows the worktree gone; EACCES or EIO shows nothing, and a
101
+ // `broken` answer would have the checkout recreated over it.
102
+ if (isAbsence(err))
103
+ return 'broken';
104
+ throw err;
105
+ }
106
+ return held.isDirectory() ? 'worktree' : 'broken';
107
+ }
108
+ /**
109
+ * Replace the contents of a file that exists, keeping the file. Git for
110
+ * Windows marks a worktree's `.git` file hidden, and Windows refuses to
111
+ * re-create a hidden file (`fs.writeFile` opens with `w`: EPERM) — so the
112
+ * file is opened as it is and truncated instead.
113
+ */
114
+ async function rewriteFile(file, text) {
115
+ const handle = await fs.open(file, 'r+');
116
+ try {
117
+ await handle.truncate(0);
118
+ await handle.write(text, 0);
119
+ }
120
+ finally {
121
+ await handle.close();
122
+ }
123
+ }
124
+ export class KbRepository {
125
+ dir;
126
+ kbRepoUrl;
127
+ gitRunner;
128
+ /** The administrator's queue: `exclusive` sections run one after another, in call order. */
129
+ admin = Promise.resolve();
130
+ /** See the contract: the repository's one administrator for `fn`'s duration. Never nested. */
131
+ async exclusive(fn) {
132
+ const next = this.admin.catch(() => undefined).then(fn);
133
+ this.admin = next;
134
+ return next;
135
+ }
136
+ /** When the last fetch succeeded, and when the last attempt failed. */
137
+ lastSuccessAt = 0;
138
+ lastFailureAt = 0;
139
+ lastOk = null;
140
+ inFlightFetch = null;
141
+ /** When the fetch in flight started: a caller that needs one from `fetchedSince` on joins only a later one. */
142
+ inFlightStartedAt = 0;
143
+ /**
144
+ * Bumped whenever the repository is taken away (set aside): a fetch that was
145
+ * in flight for the one that went must not stamp its result on the new one.
146
+ */
147
+ generation = 0;
148
+ /** The credential fingerprint last stamped into the repository's config, this process. */
149
+ stampedCredentials = null;
150
+ /** Whether this process has stamped the repository's own configuration yet. */
151
+ configStamped = false;
152
+ constructor(opts) {
153
+ this.dir = path.resolve(opts.dir);
154
+ this.kbRepoUrl = opts.kbRepoUrl;
155
+ this.gitRunner = opts.gitRunner;
156
+ }
157
+ redact(text) {
158
+ return redactSecret(text, [this.gitRunner.credentials.token(), ...urlQuerySecrets(this.kbRepoUrl())]);
159
+ }
160
+ async git(args, timeoutMs) {
161
+ const { stdout } = await this.gitRunner.run(this.dir, args, timeoutMs ? { timeoutMs } : undefined);
162
+ return stdout;
163
+ }
164
+ /**
165
+ * Whether the repository is on disk and FINISHED: a bare repository whose
166
+ * first fetch landed. The marker is written last, so a repository whose
167
+ * making was cut short (a crash, a full disk) is not taken for one.
168
+ */
169
+ async exists() {
170
+ try {
171
+ await fs.access(path.join(this.dir, READY_MARKER));
172
+ return true;
173
+ }
174
+ catch (err) {
175
+ // Only absence is "not there". A marker that could not be read (EACCES,
176
+ // EIO, a process out of file descriptors) has shown nothing, and a false
177
+ // answer would have `ensureHeld` clear a repository that is whole, with
178
+ // every worktree's unpushed commits in it.
179
+ if (isAbsence(err))
180
+ return false;
181
+ throw err;
182
+ }
183
+ }
184
+ /**
185
+ * Make sure the repository exists and has been fetched at least once; create
186
+ * it when it does not. As the administrator. Throws when it cannot be
187
+ * created or its first fetch fails — with git's words, redacted.
188
+ */
189
+ async ensure() {
190
+ await this.exclusive(() => this.ensureHeld());
191
+ }
192
+ async ensureHeld() {
193
+ if (await this.exists()) {
194
+ await this.stampConfigIfNeeded();
195
+ return;
196
+ }
197
+ const url = this.kbRepoUrl().trim();
198
+ if (url === '')
199
+ throw new Error('The knowledge-base repository address is not configured.');
200
+ // Made in place, and marked finished only once it has the remote's refs.
201
+ // What a making cut short left here holds no worktree (none is added
202
+ // before the marker) and nothing that is not on the remote: it is
203
+ // cleared and made again.
204
+ await fs.rm(this.dir, { recursive: true, force: true });
205
+ await fs.mkdir(this.dir, { recursive: true });
206
+ let fetchedAt = fetchInstant();
207
+ let credentialsStamped = true;
208
+ try {
209
+ await this.gitRunner.run(this.dir, ['init', '--bare', '--quiet', this.dir]);
210
+ for (const args of this.configArgs(url))
211
+ await this.gitRunner.run(this.dir, args);
212
+ // The helper's write is not what makes the repository: one that failed
213
+ // is tried again by `refreshCredentials`, which is why it is not
214
+ // recorded as stamped below.
215
+ for (const args of cloneCredentialConfigArgs(this.gitRunner.credentials)) {
216
+ await this.gitRunner.run(this.dir, args).catch((err) => {
217
+ if (!(args.includes('--unset-all') && err.exitCode === 5))
218
+ credentialsStamped = false;
219
+ });
220
+ }
221
+ fetchedAt = fetchInstant();
222
+ await this.gitRunner.run(this.dir, ['fetch', '--prune', '--no-write-fetch-head', '--quiet', 'origin'], {
223
+ timeoutMs: Math.max(this.gitRunner.defaultTimeoutMs, FIRST_FETCH_TIMEOUT_MS),
224
+ });
225
+ await fs.writeFile(path.join(this.dir, READY_MARKER), 'The knowledge base repository every branch workspace is a worktree of. Made by Hexis.\n');
226
+ }
227
+ catch (err) {
228
+ await fs.rm(this.dir, { recursive: true, force: true }).catch(() => undefined);
229
+ throw err;
230
+ }
231
+ this.configStamped = true;
232
+ this.stampedCredentials = credentialsStamped ? this.credentialFingerprint() : null;
233
+ this.noteFetched(true, fetchedAt);
234
+ log.info(`created the knowledge-base repository at ${this.dir}`);
235
+ }
236
+ /** The repository's own configuration — what every worktree of it reads. */
237
+ configArgs(url) {
238
+ return [
239
+ ['config', 'remote.origin.url', url],
240
+ ['config', '--replace-all', 'remote.origin.fetch', ORIGIN_FETCH_REFSPEC],
241
+ // A KB has paths over 260 characters; without this a Windows checkout drops them.
242
+ ['config', 'core.longpaths', 'true'],
243
+ ['config', 'core.packedRefsTimeout', PACKED_REFS_TIMEOUT_MS],
244
+ // No automatic collection: it would run inside whichever worktree's
245
+ // commit tripped it, beside every other worktree's work. The startup
246
+ // phase collects once per boot instead (`maintain`).
247
+ ['config', 'gc.auto', '0'],
248
+ // See `cloneTrackingConfigArgs`: an automatic gc forked into the
249
+ // background becomes a zombie under a node server.
250
+ ['config', 'gc.autoDetach', 'false'],
251
+ ['config', 'user.name', BOT_NAME],
252
+ ['config', 'user.email', BOT_EMAIL],
253
+ ];
254
+ }
255
+ credentialFingerprint() {
256
+ const { credentials } = this.gitRunner;
257
+ return `${credentials.username()}::${credentials.token() ? '1' : '0'}`;
258
+ }
259
+ /**
260
+ * Stamp the repository's configuration once per process, and its credential
261
+ * helper again whenever the deployment's credential changed — once for every
262
+ * worktree, since they all read this one config. Never throws: a stamp that
263
+ * failed leaves the repository as usable as it was, and is tried again next
264
+ * time.
265
+ */
266
+ async stampConfigIfNeeded() {
267
+ if (!this.configStamped) {
268
+ try {
269
+ // The address is NOT rewritten here: a repository of another address
270
+ // is set aside, never re-pointed (see `isOfAnotherRepository`).
271
+ for (const args of this.configArgs('').slice(1))
272
+ await this.git(args);
273
+ this.configStamped = true;
274
+ }
275
+ catch (err) {
276
+ log.warn('could not stamp the knowledge-base repository configuration:', {
277
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
278
+ });
279
+ }
280
+ }
281
+ if (this.stampedCredentials === this.credentialFingerprint())
282
+ return;
283
+ let argLists;
284
+ try {
285
+ argLists = cloneCredentialConfigArgs(this.gitRunner.credentials);
286
+ }
287
+ catch (err) {
288
+ log.warn('refusing to stamp the credential helper of the knowledge-base repository:', {
289
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
290
+ });
291
+ return;
292
+ }
293
+ let stamped = true;
294
+ for (const args of argLists) {
295
+ try {
296
+ await this.git(args);
297
+ }
298
+ catch (err) {
299
+ // `--unset-all` of a helper that is not there exits 5: nothing to do.
300
+ if (!(args.includes('--unset-all') && err.exitCode === 5))
301
+ stamped = false;
302
+ }
303
+ }
304
+ this.stampedCredentials = stamped ? this.credentialFingerprint() : null;
305
+ }
306
+ /**
307
+ * Bring the credential helper into line with the deployment's credential when
308
+ * it changed since it was last stamped. As the administrator, as every write
309
+ * to the shared config is. A no-op for a repository not yet made.
310
+ */
311
+ async refreshCredentials() {
312
+ if (this.stampedCredentials === this.credentialFingerprint() && this.configStamped)
313
+ return;
314
+ await this.exclusive(async () => {
315
+ if (await this.exists())
316
+ await this.stampConfigIfNeeded();
317
+ });
318
+ }
319
+ /**
320
+ * Run `git config` arguments against the repository's shared configuration
321
+ * (from a worktree or the repository, it is one file), one writer at a time:
322
+ * git refuses a second writer while `config.lock` is held rather than
323
+ * waiting for it.
324
+ */
325
+ async writeConfig(cwd, argLists) {
326
+ await this.exclusive(async () => {
327
+ for (const args of argLists)
328
+ await this.gitRunner.run(cwd, args);
329
+ });
330
+ }
331
+ /** See the contract. `gc --auto` decides for itself whether there is enough loose to pack. */
332
+ async maintain() {
333
+ try {
334
+ if (!(await this.exists()))
335
+ return;
336
+ await this.git(['-c', 'gc.auto=6700', 'gc', '--auto', '--quiet'], Math.max(this.gitRunner.defaultTimeoutMs, FIRST_FETCH_TIMEOUT_MS));
337
+ }
338
+ catch (err) {
339
+ log.warn('garbage collection of the knowledge-base repository failed; it is tried again at the next start:', {
340
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
341
+ });
342
+ }
343
+ }
344
+ /**
345
+ * `at` for a success is when its fetch STARTED: the refs it wrote are at
346
+ * least that fresh, and no fresher — a deletion on the remote while it ran
347
+ * may be missing from them.
348
+ */
349
+ noteFetched(ok, at = Date.now()) {
350
+ this.lastOk = ok;
351
+ if (ok)
352
+ this.lastSuccessAt = at;
353
+ else
354
+ this.lastFailureAt = at;
355
+ }
356
+ /** The last time the repository reached the remote and whether it worked; null before any attempt. */
357
+ lastContact() {
358
+ if (this.lastOk === null)
359
+ return null;
360
+ return { at: Math.max(this.lastSuccessAt, this.lastFailureAt), ok: this.lastOk };
361
+ }
362
+ /** Why the last fetch failed, redacted; null when it did not. For a fail-closed caller's message. */
363
+ lastFetchFailure() {
364
+ return this.lastOk === false ? this.lastFailure : null;
365
+ }
366
+ lastFailure = null;
367
+ /**
368
+ * `git fetch --prune origin` in the repository — ONCE for every workspace.
369
+ * Throttled by `maxAgeMs` and joined while in flight (ten callers in one
370
+ * tick share one fetch). Not serialized against the worktrees' own git:
371
+ * a fetch writes remote-tracking refs and packs, which git locks itself.
372
+ * Answers whether the remote-tracking refs are provably fresh: this call's
373
+ * fetch (or the one it joined) succeeded, or one succeeded within the
374
+ * window. Never throws; a caller that must not act on stale refs reads the
375
+ * answer.
376
+ */
377
+ async fetch(opts) {
378
+ const now = Date.now();
379
+ if (!opts.force &&
380
+ now - this.lastSuccessAt < opts.maxAgeMs &&
381
+ (opts.fetchedSince === undefined || this.lastSuccessAt > opts.fetchedSince)) {
382
+ return true;
383
+ }
384
+ if (this.inFlightFetch) {
385
+ if (opts.fetchedSince === undefined || this.inFlightStartedAt > opts.fetchedSince)
386
+ return this.inFlightFetch;
387
+ // Started before the caller's instant: its refs may be older than what
388
+ // the caller must see (a branch deleted since). Waited for, then a
389
+ // fetch after the instant is joined or run: forced, so neither the
390
+ // waited-for one's success nor its failure (the backoff) stands in for
391
+ // it. Any fetch in flight now started after the instant, so this ends.
392
+ await this.inFlightFetch;
393
+ return this.fetch({ ...opts, force: true });
394
+ }
395
+ if (!opts.force && opts.failureBackoffMs && now - this.lastFailureAt < opts.failureBackoffMs) {
396
+ return false;
397
+ }
398
+ const generation = this.generation;
399
+ let startedAt = fetchInstant();
400
+ const promise = (async () => {
401
+ startedAt = fetchInstant();
402
+ // A repository not made yet is made here, which fetches it once: that
403
+ // first fetch is this one.
404
+ const existed = await this.exists();
405
+ await this.ensure();
406
+ if (!existed)
407
+ return;
408
+ await this.git([
409
+ // A transfer that stalls below 1 KB/s for ten seconds is abandoned,
410
+ // so a hung host cannot park every workspace behind it.
411
+ '-c', 'http.lowSpeedLimit=1000',
412
+ '-c', 'http.lowSpeedTime=10',
413
+ ...SAFE_IMPLICIT_FETCH_ARGS,
414
+ ]);
415
+ })()
416
+ .then(() => {
417
+ // When this fetch made the repository, ensureHeld() already stamped its
418
+ // own (later) first-fetch start: keep that one.
419
+ if (generation === this.generation)
420
+ this.noteFetched(true, Math.max(startedAt, this.lastSuccessAt));
421
+ return true;
422
+ }, (err) => {
423
+ if (generation === this.generation) {
424
+ this.noteFetched(false);
425
+ this.lastFailure = this.redact(err instanceof Error ? err.message : String(err));
426
+ }
427
+ log.warn('git fetch origin failed:', {
428
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
429
+ });
430
+ return false;
431
+ })
432
+ .finally(() => {
433
+ if (this.inFlightFetch === promise)
434
+ this.inFlightFetch = null;
435
+ });
436
+ this.inFlightFetch = promise;
437
+ this.inFlightStartedAt = startedAt;
438
+ return promise;
439
+ }
440
+ /**
441
+ * Forget what this process knows about the repository — it was set aside,
442
+ * and the next use makes a fresh one.
443
+ */
444
+ forget() {
445
+ this.generation++;
446
+ this.lastSuccessAt = 0;
447
+ this.lastFailureAt = 0;
448
+ this.lastOk = null;
449
+ this.inFlightFetch = null;
450
+ this.configStamped = false;
451
+ this.stampedCredentials = null;
452
+ }
453
+ async revParse(ref) {
454
+ try {
455
+ return (await this.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])).trim() || null;
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ }
461
+ async isAncestor(ancestor, of) {
462
+ try {
463
+ await this.git(['merge-base', '--is-ancestor', ancestor, of]);
464
+ return true;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
470
+ /**
471
+ * Add the worktree of `branch` at `checkoutDir`. Copies no history: the
472
+ * repository already has it, and is fetched first when the remote's branch
473
+ * is not known to it yet.
474
+ *
475
+ * The branch's local ref is created from `origin/<branch>`, tracking it. A
476
+ * local ref already there (a converted clone's tip, a worktree that was lost
477
+ * with commits the remote lacks) is KEPT when it holds commits the remote
478
+ * does not have, and fast-forwarded to the remote when it is behind.
479
+ *
480
+ * A branch the remote does not have fails with git's own words for a clone
481
+ * of it ("Remote branch … not found in upstream origin"), so the callers
482
+ * that tell a deleted branch from one that never existed read it as they
483
+ * read a clone's failure — unless `allowLocalOnly` (a converted clone whose
484
+ * branch was never pushed) and the local ref exists.
485
+ *
486
+ * `keepLocalTip` (a conversion) leaves a local ref that is behind the
487
+ * remote where it is: the worktree comes up at the clone's own tip, and the
488
+ * phase's own update moves it on, as it moved the clone.
489
+ *
490
+ * `fetchedSince` (the startup phase, opening every branch) skips asking
491
+ * the remote when the repository's own fetch succeeded, starting after it,
492
+ * and already has `origin/<branch>`: that fetch brought every branch, so
493
+ * opening a hundred branches costs one fetch, not a hundred. A branch it
494
+ * does not know yet is asked for as usual.
495
+ */
496
+ async addWorktree(branch, checkoutDir, opts = {}) {
497
+ await this.ensure();
498
+ {
499
+ // The remote is ASKED for the branch, as a clone of it asked: the
500
+ // repository's tracking ref may be from before the branch moved, or was
501
+ // deleted, on the host. One branch, not the whole remote. Not as the
502
+ // administrator: a fetch may take a while, and git locks what it writes.
503
+ let missing = false;
504
+ const fresh = opts.fetchedSince !== undefined &&
505
+ this.lastSuccessAt > 0 &&
506
+ this.lastSuccessAt > opts.fetchedSince &&
507
+ (await this.revParse(`refs/remotes/origin/${branch}`)) !== null;
508
+ if (!fresh) {
509
+ try {
510
+ await this.git(['fetch', '--no-write-fetch-head', '--quiet', 'origin',
511
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
512
+ }
513
+ catch (err) {
514
+ const message = err instanceof Error ? err.message : String(err);
515
+ if (!/couldn't find remote ref/i.test(message))
516
+ throw err;
517
+ missing = true;
518
+ // What the repository still says about it is stale.
519
+ await this.git(['update-ref', '-d', `refs/remotes/origin/${branch}`]).catch(() => undefined);
520
+ }
521
+ }
522
+ // As the administrator, from reading the local ref to the config
523
+ // writes: the worktree list is the one thing git does not lock (a prune
524
+ // beside another branch's `worktree add` can take the entry being
525
+ // made), `worktree add --track` writes the shared config, which git
526
+ // refuses a second writer of, and a `removeWorktree` queued before this
527
+ // section deletes the branch's local ref — read here, it is the ref
528
+ // the add uses, not one read before that removal and gone by then.
529
+ await this.exclusive(async () => {
530
+ const local = await this.revParse(`refs/heads/${branch}`);
531
+ if (missing && !(opts.allowLocalOnly && local !== null)) {
532
+ throw new Error(`Remote branch ${branch} not found in upstream origin`);
533
+ }
534
+ await fs.mkdir(path.dirname(checkoutDir), { recursive: true });
535
+ // A registration left behind by a folder deleted without git (a crash,
536
+ // an operator) would make git refuse the path, or the branch, as taken.
537
+ await this.git(['worktree', 'prune']);
538
+ if (local === null) {
539
+ await this.git(['worktree', 'add', '--quiet', '--track', '-b', branch, checkoutDir, `refs/remotes/origin/${branch}`]);
540
+ }
541
+ else {
542
+ const remote = missing ? null : await this.revParse(`refs/remotes/origin/${branch}`);
543
+ if (!opts.keepLocalTip && remote !== null && local !== remote && (await this.isAncestor(local, remote))) {
544
+ await this.git(['update-ref', `refs/heads/${branch}`, remote, local]);
545
+ }
546
+ await this.git(['worktree', 'add', '--quiet', checkoutDir, branch]);
547
+ }
548
+ // The branch tracks `origin/<branch>`, exactly one fetch refspec and
549
+ // one upstream ref, as a fresh clone of it would.
550
+ for (const args of cloneTrackingConfigArgs(branch))
551
+ await this.git(args);
552
+ });
553
+ }
554
+ }
555
+ /**
556
+ * Remove the worktree at `checkoutDir` and prune it from the repository,
557
+ * with its branch's local ref: deleting a workspace takes what a deleted
558
+ * clone took. The repository's objects and every other worktree are left
559
+ * exactly as they were. A folder git will not remove (it is not a worktree
560
+ * any more, or is locked) is deleted by hand, then pruned.
561
+ */
562
+ async removeWorktree(checkoutDir, branch) {
563
+ if (!(await this.exists())) {
564
+ await fs.rm(checkoutDir, { recursive: true, force: true });
565
+ return;
566
+ }
567
+ // As the administrator, the branch delete included: the worktree list
568
+ // is the repository's to serialize (see `addWorktree`), the delete
569
+ // removes the branch's section from the shared config, and an
570
+ // `addWorktree` of the same branch must find the ref either there or
571
+ // gone, not read it and then lose it.
572
+ await this.exclusive(async () => {
573
+ await this.git(['worktree', 'remove', '--force', '--force', checkoutDir]).catch(async () => {
574
+ await fs.rm(checkoutDir, { recursive: true, force: true });
575
+ });
576
+ await this.git(['worktree', 'prune']);
577
+ await this.git(['branch', '-D', '--', branch]).catch(() => undefined);
578
+ });
579
+ }
580
+ /**
581
+ * After worktree folders were deleted by hand (the orphan sweep): prune them
582
+ * from the repository and drop their branches' local refs. A branch still
583
+ * checked out in a worktree that exists is refused by git and kept.
584
+ */
585
+ async forgetWorktrees(branches) {
586
+ if (!(await this.exists()))
587
+ return;
588
+ // As the administrator, prune and deletes together: the worktree list is
589
+ // the repository's to serialize, and a branch delete writes the shared
590
+ // config (see `removeWorktree`).
591
+ await this.exclusive(async () => {
592
+ await this.git(['worktree', 'prune']);
593
+ for (const branch of branches) {
594
+ if ((await this.revParse(`refs/heads/${branch}`)) === null)
595
+ continue;
596
+ await this.git(['branch', '-D', '--', branch]).catch(() => undefined);
597
+ }
598
+ });
599
+ }
600
+ /**
601
+ * `git worktree prune`: forget worktrees whose folders are gone. As the
602
+ * administrator: a prune beside a `worktree add` can take the entry being
603
+ * made, before git has marked it as in the making.
604
+ */
605
+ async prune() {
606
+ if (!(await this.exists()))
607
+ return;
608
+ await this.exclusive(() => this.git(['worktree', 'prune']));
609
+ }
610
+ /** The folders of the repository's worktrees, as git lists them. */
611
+ async listWorktrees() {
612
+ if (!(await this.exists()))
613
+ return [];
614
+ const out = await this.git(['worktree', 'list', '--porcelain']);
615
+ const dirs = [];
616
+ for (const line of out.split('\n')) {
617
+ if (!line.startsWith('worktree '))
618
+ continue;
619
+ const dir = path.resolve(line.slice('worktree '.length).trim());
620
+ if (dir !== this.dir)
621
+ dirs.push(dir);
622
+ }
623
+ return dirs;
624
+ }
625
+ /**
626
+ * Bring the tip of `branch` from the full clone at `cloneDir` into the
627
+ * repository as `refs/heads/<branch>` — its commits the remote lacks
628
+ * included — without touching the remote. Answers the tip, after checking
629
+ * the repository now has it.
630
+ */
631
+ async importBranch(cloneDir, branch) {
632
+ await this.ensure();
633
+ const tip = (await this.gitRunner.run(cloneDir, ['rev-parse', '--verify', `refs/heads/${branch}^{commit}`])).stdout.trim();
634
+ await this.git(['fetch', '--no-tags', '--no-write-fetch-head', '--quiet', cloneDir,
635
+ `+refs/heads/${branch}:refs/heads/${branch}`]);
636
+ const imported = await this.revParse(`refs/heads/${branch}`);
637
+ if (imported !== tip) {
638
+ throw new Error(`the repository holds ${imported ?? 'nothing'} for "${branch}" after the import, not ${tip}`);
639
+ }
640
+ return tip;
641
+ }
642
+ /**
643
+ * Bring what else the full clone at `cloneDir` holds of its own into the
644
+ * repository, under `refs/kb-converted/<workspaceId>/`, before the clone
645
+ * goes: every entry of its stash (`stash/0` the latest), and every local
646
+ * ref but its own branch's and the remote-tracking ones — other local
647
+ * branches, tags — that the repository does not already hold with the same
648
+ * value. Nothing in the clone changes: each object is pushed by its id.
649
+ * Answers the refs written, after checking the repository holds each at the
650
+ * clone's value; throws, the clone untouched, when one cannot be written.
651
+ *
652
+ * The refs are no workspace's: deleting the branch's workspace leaves them,
653
+ * and the repository's collection keeps what they reach.
654
+ */
655
+ async importLocalRefs(cloneDir, branch, workspaceId) {
656
+ await this.ensure();
657
+ const inClone = async (args) => (await this.gitRunner.run(cloneDir, args)).stdout;
658
+ const namespace = `${CONVERTED_REFS}/${workspaceId}`;
659
+ const wanted = [];
660
+ let stashTop = null;
661
+ for (const line of (await inClone(['for-each-ref', '--format=%(objectname) %(refname)'])).split('\n')) {
662
+ const [sha, name] = line.trim().split(' ');
663
+ if (!sha || !name)
664
+ continue;
665
+ if (name === 'refs/stash') {
666
+ stashTop = sha;
667
+ continue;
668
+ }
669
+ if (name.startsWith('refs/remotes/') || name === `refs/heads/${branch}`)
670
+ continue;
671
+ if ((await this.refValue(name)) === sha)
672
+ continue;
673
+ if (name.startsWith('refs/heads/') && (await this.refValue(`refs/remotes/origin/${name.slice('refs/heads/'.length)}`)) === sha)
674
+ continue;
675
+ wanted.push({ sha, dest: `${namespace}/${name.slice('refs/'.length)}` });
676
+ }
677
+ if (stashTop !== null) {
678
+ // Every entry, not only the latest: the older ones are the stash's log.
679
+ // Git reads a log it cannot open (EACCES, EIO) as an empty one and
680
+ // exits 0, so the log is read here first: only an absent one means the
681
+ // stash is its tip alone, and any other failure throws, the clone kept.
682
+ const logFile = path.resolve(cloneDir, (await inClone(['rev-parse', '--git-path', 'logs/refs/stash'])).trim());
683
+ let logged;
684
+ try {
685
+ logged = (await fs.readFile(logFile, 'utf8')).split('\n').filter((l) => l.trim() !== '').length;
686
+ }
687
+ catch (err) {
688
+ if (!isAbsence(err))
689
+ throw err;
690
+ logged = 0;
691
+ }
692
+ const entries = (await inClone(['reflog', 'show', '--format=%H', 'refs/stash']))
693
+ .split('\n').map((l) => l.trim()).filter(Boolean);
694
+ if (entries.length !== logged) {
695
+ throw new Error(`the stash log of ${cloneDir} holds ${logged} entries, git read ${entries.length}`);
696
+ }
697
+ if (logged > 0 && entries[0] !== stashTop) {
698
+ throw new Error(`the stash log of ${cloneDir} starts at ${entries[0]}, not at the stash tip ${stashTop}`);
699
+ }
700
+ (logged > 0 ? entries : [stashTop]).forEach((sha, i) => wanted.push({ sha, dest: `${namespace}/stash/${i}` }));
701
+ }
702
+ if (wanted.length === 0)
703
+ return [];
704
+ await this.gitRunner.run(cloneDir, ['push', '--quiet', '--no-verify', this.dir,
705
+ ...wanted.map(({ sha, dest }) => `+${sha}:${dest}`)]);
706
+ for (const { sha, dest } of wanted) {
707
+ const held = await this.refValue(dest);
708
+ if (held !== sha)
709
+ throw new Error(`the repository holds ${held ?? 'nothing'} at ${dest} after the import, not ${sha}`);
710
+ }
711
+ return wanted.map(({ dest }) => dest);
712
+ }
713
+ /** The object a ref names, as it is (a tag is not peeled), or null. */
714
+ async refValue(ref) {
715
+ try {
716
+ return (await this.git(['rev-parse', '--verify', '--quiet', ref])).trim() || null;
717
+ }
718
+ catch {
719
+ return null;
720
+ }
721
+ }
722
+ /**
723
+ * The address the repository fetches from, or null when it cannot be read
724
+ * (no repository, no `origin`).
725
+ */
726
+ async originUrl() {
727
+ if (!(await this.exists()))
728
+ return null;
729
+ try {
730
+ return (await this.git(['config', '--get', 'remote.origin.url'])).trim() || null;
731
+ }
732
+ catch {
733
+ return null;
734
+ }
735
+ }
736
+ /**
737
+ * Re-link a repository and its worktrees that were moved together (set
738
+ * aside), so `git log` in a moved worktree reads the moved repository.
739
+ *
740
+ * Both links are written here rather than left to `git worktree repair`,
741
+ * which cannot follow a link whose two ends BOTH moved: the worktree's
742
+ * `.git` file names `<repository>/worktrees/<name>`, and that folder's
743
+ * `gitdir` names the worktree's `.git`. The name is the last part of the
744
+ * path the worktree's `.git` file still holds. Answers the moved worktrees
745
+ * that could not be re-linked (git had already lost track of them, or a
746
+ * write failed) — their files are all there, only `git` in them is not.
747
+ * A worktree whose second link cannot be written gets its first one back,
748
+ * so neither link names a place the other does not.
749
+ */
750
+ async relinkMovedCopy(movedRepository, movedWorktrees) {
751
+ const gitPath = (p) => (process.platform === 'win32' ? p.replace(/\\/g, '/') : p);
752
+ const unlinked = [];
753
+ for (const worktree of movedWorktrees) {
754
+ try {
755
+ const gitfile = path.join(worktree, '.git');
756
+ const old = await readGitfile(gitfile);
757
+ if (old === null)
758
+ throw new Error('its .git file names no folder');
759
+ const admin = path.join(movedRepository, 'worktrees', path.basename(old));
760
+ if (!(await fs.stat(admin).then((st) => st.isDirectory(), () => false))) {
761
+ throw new Error(`the repository has no ${admin}`);
762
+ }
763
+ // Both links or neither: a `.git` re-pointed while the record behind
764
+ // it still names the old checkout is a worktree git calls prunable,
765
+ // and a `worktree prune` in the set-aside repository would drop it.
766
+ const before = await fs.readFile(gitfile, 'utf8');
767
+ // Written as the file system names them, as git itself writes them:
768
+ // on Windows a path held by its 8.3 short name (`RUNNER~1`) would
769
+ // otherwise be what `git worktree list` shows.
770
+ const realAdmin = await fs.realpath(admin);
771
+ const realGitfile = path.join(await fs.realpath(worktree), '.git');
772
+ await rewriteFile(gitfile, `gitdir: ${gitPath(realAdmin)}\n`);
773
+ try {
774
+ await rewriteFile(path.join(admin, 'gitdir'), `${gitPath(realGitfile)}\n`);
775
+ }
776
+ catch (err) {
777
+ await rewriteFile(gitfile, before).catch((backErr) => {
778
+ log.error(`the .git file of the set-aside worktree ${worktree} could not be restored:`, {
779
+ detail: this.redact(backErr instanceof Error ? backErr.message : String(backErr)),
780
+ });
781
+ });
782
+ throw err;
783
+ }
784
+ }
785
+ catch (err) {
786
+ unlinked.push(worktree);
787
+ log.warn(`the set-aside worktree ${worktree} could not be re-linked to the set-aside repository:`, {
788
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
789
+ });
790
+ }
791
+ }
792
+ return unlinked;
793
+ }
794
+ /**
795
+ * Point the repository at `url` — the same repository at a new address.
796
+ * What this process knew of the old address's fetches is forgotten with it,
797
+ * so the next fetch reaches the new one rather than reading a success from
798
+ * the old one as fresh.
799
+ */
800
+ async setOriginUrl(url) {
801
+ await this.exclusive(async () => {
802
+ await this.git(['remote', 'set-url', 'origin', url]);
803
+ this.generation++;
804
+ this.lastSuccessAt = 0;
805
+ this.lastFailureAt = 0;
806
+ this.lastOk = null;
807
+ this.lastFailure = null;
808
+ });
809
+ }
810
+ }
811
+ //# sourceMappingURL=kb-repository.js.map