@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
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
2
3
  import { logger } from '../../shared/logging.js';
3
4
 
4
5
  const log = logger('workspace');
@@ -44,9 +45,10 @@ import {
44
45
  import { assertValidBranchName } from '../kb-fs/branch-name.js';
45
46
  import { normalizeRepositoryAddress, sameRepository } from '../kb-fs/remote-url.js';
46
47
  import { redactSecret, urlQuerySecrets } from '../../shared/redact-secret.js';
47
- import { setAsideClone, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
48
+ import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from './set-aside-clone.js';
49
+ import { workingCopyShape } from './kb-repository.js';
50
+ import type { IKbRepository } from './kb-repository.contract.js';
48
51
  import {
49
- cloneCredentialArgs,
50
52
  cloneCredentialConfigArgs,
51
53
  credentialHelperValue,
52
54
  cloneTrackingConfigArgs,
@@ -72,12 +74,18 @@ interface HeldTurn {
72
74
  * Directory layout on disk:
73
75
  * <workspacesRoot>/
74
76
  * <encodeURIComponent(branch)>/
75
- * knowledge-base/ # the git clone checked out to <branch>
77
+ * knowledge-base/ # a worktree of the KB repository, on <branch>
78
+ * <repository>/knowledge-base.git # the one repository (see `kb-repository.ts`)
76
79
  *
77
- * Bootstrap is lazy: the first call to `getOrCreateForBranch(branch)` clones
78
- * the repo with `-b <branch>`. Subsequent callers — whether the same user or
79
- * a different one — share the same directory and the same git operations are
80
- * serialised through the workspace mutex elsewhere in the codebase.
80
+ * Bootstrap is lazy: the first call to `getOrCreateForBranch(branch)` adds
81
+ * the branch's worktree, which copies no history. Subsequent callers — whether
82
+ * the same user or a different one — share the same directory and the same
83
+ * git operations are serialised through the workspace mutex elsewhere in the
84
+ * codebase.
85
+ *
86
+ * A checkout folder that is still a full CLONE (from a release before
87
+ * worktrees, which the KB startup phase converts at boot) is served as it is
88
+ * until it is converted: every method here accepts both shapes.
81
89
  */
82
90
 
83
91
  export interface UnzipResult {
@@ -150,23 +158,6 @@ export class EntryExistsError extends WorkflowDomainError {
150
158
  }
151
159
  }
152
160
 
153
- /**
154
- * A floor under the git deadline for a clone: a first clone of a large
155
- * knowledge base over a slow link legitimately takes minutes, and the port's
156
- * default is sized for a running deployment's operations. A configured
157
- * ceiling above this applies as configured.
158
- */
159
- const CLONE_TIMEOUT_MS = 600_000;
160
-
161
- /**
162
- * Identity stamped on the per-branch clone's git config. Every workflow
163
- * commit overrides this with `--author=…` so the human shows up in
164
- * `git log`; this is just the fallback committer identity so plain
165
- * git operations have something to attribute to.
166
- */
167
- const BOT_NAME = 'Bevel Workflow';
168
- const BOT_EMAIL = 'bevel-workflow@bevel.software';
169
-
170
161
  /** Redact the token the runner authenticates with from an error message. */
171
162
  function redactError(err: unknown, token: string | null): string {
172
163
  const msg = err instanceof Error ? err.message : String(err);
@@ -333,7 +324,7 @@ export class WorkspaceService implements IWorkspaceService {
333
324
  *
334
325
  * Why, in one place: `setAsideRootFor` in `set-aside-clone.ts`.
335
326
  */
336
- private readonly setAsideRoot?: string,
327
+ private readonly setAsideRoot: string | undefined,
337
328
  /**
338
329
  * What surrounds the setting aside of ONE working copy when a branch is
339
330
  * opened and its clone turns out to be of another repository: given the
@@ -348,10 +339,59 @@ export class WorkspaceService implements IWorkspaceService {
348
339
  _workspaceId,
349
340
  move,
350
341
  ) => move(),
342
+ /**
343
+ * The one repository every branch workspace is a worktree of — THE SAME
344
+ * INSTANCE THE KB STARTUP PHASE AND THE GIT LAYER USE, so that one fetch
345
+ * serves them all and its reservation is one. Built by the composition
346
+ * root, never here.
347
+ */
348
+ readonly repository: IKbRepository,
351
349
  ) {
352
350
  this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
353
351
  }
354
352
 
353
+ /**
354
+ * Whether the checkout folder `repoDir` is a worktree (its `.git` a file)
355
+ * rather than a full clone. A stat, no git.
356
+ */
357
+ private async isWorktree(repoDir: string): Promise<boolean> {
358
+ // Synchronous on purpose: it is one stat of a local file, and it sits in
359
+ // front of the fetch joins (`ensureRemotesFetched`), where an await
360
+ // between two concurrent callers' in-flight checks is what lets both
361
+ // start a fetch.
362
+ try {
363
+ return statSync(path.join(repoDir, '.git')).isFile();
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Whether `repoDir` holds a working copy that can be served: a worktree of
371
+ * the repository or a full clone. A worktree whose link to the repository is
372
+ * broken is NOT one — it is an unfinished checkout, recreated on the next
373
+ * open. Errors other than absence propagate.
374
+ */
375
+ private async hasWorkingCopy(repoDir: string): Promise<boolean> {
376
+ const shape = await workingCopyShape(repoDir);
377
+ return shape === 'worktree' || shape === 'clone';
378
+ }
379
+
380
+ /**
381
+ * The repository behind a workspace when it is a worktree of it; null for a
382
+ * full clone (which fetches on its own) or a workspace with no checkout.
383
+ * For the git layer, which keys its fetches on this.
384
+ */
385
+ async sharedRepositoryOf(workspaceId: string): Promise<IKbRepository | null> {
386
+ const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
387
+ return this.repositoryOfCheckout(path.join(workspaceDir, this.kbDirName));
388
+ }
389
+
390
+ /** {@link sharedRepositoryOf}, for a caller that already has the checkout folder. */
391
+ async repositoryOfCheckout(repoDir: string): Promise<IKbRepository | null> {
392
+ return (await this.isWorktree(repoDir)) ? this.repository : null;
393
+ }
394
+
355
395
  /**
356
396
  * The set-asides in flight, one per branch. Two callers opening the same
357
397
  * branch both find the clone of another repository; the second waits for
@@ -488,63 +528,6 @@ export class WorkspaceService implements IWorkspaceService {
488
528
  }
489
529
  }
490
530
 
491
- /**
492
- * Build git clone args. Credentials go in as `clone --config`, which both
493
- * authenticates this clone and leaves the helper in the new repo's config for
494
- * every later push — and keeps the token itself out of the repo URL, out of
495
- * argv, and out of `.git/config` (the helper reads it from the environment).
496
- */
497
- private gitCloneArgs(targetDir: string, branch: string, referenceRepo?: string): string[] {
498
- // core.longpaths=true: KB has paths >260 chars; without this the clone silently drops those files on Windows.
499
- const args = ['clone', '-c', 'core.longpaths=true', '-b', branch];
500
- // Borrow the object database of an existing sibling clone. Every commit
501
- // in the KB is already on disk locally, so object transfer drops to
502
- // ~zero and a cold branch switch pays only the ref-negotiation
503
- // round-trip instead of a full download. `--dissociate` copies the
504
- // borrowed objects into the new repo and drops the alternates link, so
505
- // the new clone stays independent even if the sibling is later swept
506
- // or gc'd. Still a real clone of the real remote — all `refs/remotes/
507
- // origin/*` are populated as usual.
508
- if (referenceRepo) {
509
- args.push('--reference', referenceRepo, '--dissociate');
510
- }
511
- // Persisted into the new repo's config (`clone --config`), not just applied
512
- // to this invocation — every later push from this clone depends on finding
513
- // the helper there. See `cloneCredentialArgs`.
514
- args.push(...cloneCredentialArgs(this.gitRunner.credentials));
515
- args.push(this.kbRepoUrl(), targetDir);
516
- return args;
517
- }
518
-
519
- /**
520
- * Find an existing workspace clone on disk to use as a `--reference` for
521
- * a new clone. Any clone of the KB works — they all share the same object
522
- * history. Returns the absolute path to the inner repo dir, or null when
523
- * no sibling exists (the first branch after a cold start). Scans disk
524
- * rather than the in-memory `branchDirs` map so clones that survived a
525
- * process restart are still found.
526
- */
527
- private async findSiblingRepo(excludeBranch: string): Promise<string | null> {
528
- const excludeId = workspaceIdForBranch(excludeBranch);
529
- let entries: Array<{ name: string; isDirectory: () => boolean }>;
530
- try {
531
- entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
532
- } catch {
533
- return null;
534
- }
535
- for (const entry of entries) {
536
- if (!entry.isDirectory() || entry.name === excludeId) continue;
537
- const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
538
- try {
539
- await fs.access(path.join(repoDir, '.git'));
540
- return repoDir;
541
- } catch {
542
- // No `.git` — half-built or unrelated dir; keep looking.
543
- }
544
- }
545
- return null;
546
- }
547
-
548
531
  /**
549
532
  * Return the id of any workspace already cloned on disk, or null if none
550
533
  * exists. Every clone tracks the same origin, so for a *repo-global* operation
@@ -578,12 +561,9 @@ export class WorkspaceService implements IWorkspaceService {
578
561
  for (const entry of entries) {
579
562
  if (!entry.isDirectory()) continue;
580
563
  const repoDir = path.join(this.workspacesRoot, entry.name, this.kbDirName);
581
- try {
582
- await fs.access(path.join(repoDir, '.git'));
583
- } catch {
584
- // No `.git` — half-built or unrelated dir; keep looking.
585
- continue;
586
- }
564
+ // No working copy — half-built, a worktree whose repository is gone, or
565
+ // an unrelated dir; keep looking.
566
+ if (!(await this.hasWorkingCopy(repoDir).catch(() => false))) continue;
587
567
  await this.refreshCredentialHelperIfStale(branchForWorkspaceId(entry.name), repoDir);
588
568
  return entry.name;
589
569
  }
@@ -635,7 +615,9 @@ export class WorkspaceService implements IWorkspaceService {
635
615
  continue;
636
616
  }
637
617
  try {
638
- await fs.access(path.join(this.workspacesRoot, entry.name, this.kbDirName, '.git'));
618
+ // A worktree whose repository is gone is not a working copy to pull:
619
+ // it is recreated when the branch is next opened.
620
+ if (!(await this.hasWorkingCopy(path.join(this.workspacesRoot, entry.name, this.kbDirName)))) continue;
639
621
  } catch (err) {
640
622
  if (isAbsence(err)) continue;
641
623
  cloned.push({
@@ -655,36 +637,6 @@ export class WorkspaceService implements IWorkspaceService {
655
637
  return cloned;
656
638
  }
657
639
 
658
- /**
659
- * Run the clone. When a `referenceRepo` is supplied and the referenced
660
- * clone fails (e.g. the sibling's object store is corrupt or mid-write),
661
- * fall back once to a plain network clone so a bad sibling can never
662
- * block a new branch from bootstrapping.
663
- */
664
- private async runClone(
665
- targetDir: string,
666
- branch: string,
667
- referenceRepo: string | null,
668
- ): Promise<void> {
669
- // Long enough for a first clone, whatever the configured ceiling says.
670
- const timeoutMs = Math.max(this.gitRunner.defaultTimeoutMs, CLONE_TIMEOUT_MS);
671
- if (referenceRepo) {
672
- try {
673
- await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch, referenceRepo), {
674
- timeoutMs,
675
- });
676
- return;
677
- } catch (err) {
678
- log.warn(`referenced clone for "${branch}" failed, retrying without reference:`, {
679
- detail: redactError(err, this.gitRunner.credentials.token()),
680
- });
681
- // Clear any partial output so the retry clones into a clean dir.
682
- await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
683
- }
684
- }
685
- await this.gitRunner.run(path.dirname(targetDir), this.gitCloneArgs(targetDir, branch), { timeoutMs });
686
- }
687
-
688
640
  /**
689
641
  * Get-or-create the per-branch workspace. Idempotent — repeat calls for
690
642
  * the same branch reuse the existing clone. Concurrent first-callers
@@ -723,9 +675,11 @@ export class WorkspaceService implements IWorkspaceService {
723
675
  return this.buildWorkspaceInfo(branch, workspaceDir);
724
676
  }
725
677
 
726
- // Check disk — the clone may have been bootstrapped by a previous
727
- // process and survived a restart.
728
- const onDisk = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
678
+ // Check disk — the worktree (or a clone from an earlier release) may have
679
+ // been made by a previous process and survived a restart. A worktree whose
680
+ // repository is gone is not on disk in any useful sense: bootstrap below
681
+ // recreates it.
682
+ const onDisk = await this.hasWorkingCopy(repoDir).catch(() => false);
729
683
  // A clone fetches and pushes through the address stored in its OWN
730
684
  // `remote.origin.url`, which nothing rewrites when the configured
731
685
  // repository changes. Adopting one of the repository that was replaced is
@@ -757,7 +711,14 @@ export class WorkspaceService implements IWorkspaceService {
757
711
  // Someone else is moving it. Wait, then clone below; a move that failed
758
712
  // refuses this caller with the same reason.
759
713
  await setAsideInFlight;
760
- } else if (onDisk && (await this.isCloneOfAnotherRepository(repoDir))) {
714
+ } else if (
715
+ onDisk
716
+ ? await this.isCloneOfAnotherRepository(repoDir)
717
+ : // Nothing of this branch on disk yet, but the worktree it is about to
718
+ // get would be of the one repository — whose address may be the
719
+ // replaced one, until the startup phase has been through.
720
+ await this.isRepositoryOfAnother()
721
+ ) {
761
722
  // Asked again: another caller may have started the move while the
762
723
  // address was being read.
763
724
  const startedMeanwhile = this.inFlightSetAsides.get(branch);
@@ -772,13 +733,51 @@ export class WorkspaceService implements IWorkspaceService {
772
733
  // at this path NOW, which is the fresh clone the first caller is in
773
734
  // the middle of making. Nothing is left to move; the bootstrap below
774
735
  // waits for that clone.
736
+ } else if (!onDisk || (await this.isWorktree(repoDir))) {
737
+ // A worktree of a repository of another address: the address is the
738
+ // REPOSITORY's, and its commits are the repository's, so the repository
739
+ // and every worktree of it are set aside together, into one folder.
740
+ const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp());
741
+ const ids = await this.worktreeIdsOnDisk();
742
+ if (onDisk && !ids.includes(id)) ids.push(id);
743
+ let movedIds: string[] | null = null;
744
+ const move = async (): Promise<void> => {
745
+ if (this.discardsOf(id) !== discardsAtAdoption) return;
746
+ // Asked again under the reservation: two branches opened at once
747
+ // both read the old address, and the first one's set-aside and fresh
748
+ // repository may be done by the time this one holds it. Moving then
749
+ // would set aside the replacement and the first branch's worktree.
750
+ let superseded = false;
751
+ const ids = await setAsideRepository(this.repository, this.workspacesRoot, this.kbDirName, kept, async () => {
752
+ superseded = !(await this.isRepositoryOfAnother());
753
+ return !superseded;
754
+ });
755
+ if (superseded) return;
756
+ movedIds = ids;
757
+ for (const movedId of movedIds) this.forgetClone(movedId);
758
+ };
759
+ // What surrounds a set-aside is asked for every workspace that goes,
760
+ // nested, so the move runs once with all of them held.
761
+ const wrapped = ids.reduce<() => Promise<void>>((inner, wid) => () => this.aroundSetAside(wid, inner), move);
762
+ const moving = this.settleSetAside(wrapped(), () => movedIds !== null, branch).finally(() => {
763
+ this.inFlightSetAsides.delete(branch);
764
+ });
765
+ this.inFlightSetAsides.set(branch, moving);
766
+ await moving;
767
+ if (movedIds) {
768
+ log.warn(
769
+ `the knowledge-base repository and its ${(movedIds as string[]).length} worktree(s) were of another ` +
770
+ `repository. Set aside together at ${kept}; nothing was deleted, and "${branch}" is being opened ` +
771
+ 'fresh from the configured one. Work that was never pushed is in that folder.',
772
+ );
773
+ }
775
774
  } else {
776
775
  const kept = path.join(setAsideRootFor(this.workspacesRoot, this.setAsideRoot), setAsideStamp(), id);
777
776
  // What surrounds the move may wait, and the startup phase may set
778
777
  // this very copy aside meanwhile. Then there is nothing left to move:
779
778
  // the phase did it, and said so.
780
779
  let moved = false;
781
- const moving = this.aroundSetAside(id, async () => {
780
+ const move = async (): Promise<void> => {
782
781
  if (this.discardsOf(id) !== discardsAtAdoption) return;
783
782
  await setAsideClone(repoDir, kept);
784
783
  moved = true;
@@ -787,7 +786,8 @@ export class WorkspaceService implements IWorkspaceService {
787
786
  // that looked at the old copy before this line knows its answer is
788
787
  // stale, however late it comes back.
789
788
  this.forgetClone(id);
790
- }).finally(() => {
789
+ };
790
+ const moving = this.settleSetAside(this.aroundSetAside(id, move), () => moved, branch).finally(() => {
791
791
  this.inFlightSetAsides.delete(branch);
792
792
  });
793
793
  this.inFlightSetAsides.set(branch, moving);
@@ -963,7 +963,39 @@ export class WorkspaceService implements IWorkspaceService {
963
963
  * process that does not know where the repository is has no business
964
964
  * declaring a clone stale.
965
965
  */
966
- private async isCloneOfAnotherRepository(repoDir: string): Promise<boolean> {
966
+ /**
967
+ * Wait for a set-aside. Its failure refuses the branch only while nothing
968
+ * has moved: once the move happened, what failed is what runs AFTER it
969
+ * around the move (`aroundSetAside` looking for the copy's work again,
970
+ * which already retries itself), and refusing then would report a move that
971
+ * did happen as one that did not. That failure is logged, and the branch is
972
+ * opened fresh.
973
+ */
974
+ private async settleSetAside(moving: Promise<void>, didMove: () => boolean, branch: string): Promise<void> {
975
+ try {
976
+ await moving;
977
+ } catch (err) {
978
+ if (!didMove()) throw err;
979
+ log.error(
980
+ `the working copy of "${branch}" was set aside, but what surrounds the move failed after it; the branch ` +
981
+ 'is opened fresh:',
982
+ { detail: redactError(err, this.gitRunner.credentials.token()) },
983
+ );
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Whether the one repository is on disk, finished, and of an address that
989
+ * is not the configured one. For a branch opened with nothing on disk: its
990
+ * worktree would be added to that repository and fetch from the old
991
+ * address.
992
+ */
993
+ private async isRepositoryOfAnother(): Promise<boolean> {
994
+ if (!(await this.repository.exists())) return false;
995
+ return this.isCloneOfAnotherRepository(this.repository.dir, 'knowledge-base repository');
996
+ }
997
+
998
+ private async isCloneOfAnotherRepository(repoDir: string, label?: string): Promise<boolean> {
967
999
  const configured = this.kbRepoUrl().trim();
968
1000
  if (configured === '') return false;
969
1001
  let origin: string;
@@ -979,7 +1011,7 @@ export class WorkspaceService implements IWorkspaceService {
979
1011
  // signed URL — see `urlQuerySecrets`). So the address is scrubbed on the
980
1012
  // way into the log, exactly as the startup phase scrubs its own.
981
1013
  log.warn(
982
- `the "${path.basename(path.dirname(repoDir))}" working copy is a clone of another repository ` +
1014
+ `the ${label ?? `"${path.basename(path.dirname(repoDir))}" working copy`} is a clone of another repository ` +
983
1015
  `(${redactSecret(normalizeRepositoryAddress(origin), urlQuerySecrets(origin))}).`,
984
1016
  );
985
1017
  return true;
@@ -1064,6 +1096,20 @@ export class WorkspaceService implements IWorkspaceService {
1064
1096
  * tracking keys before it pulls.
1065
1097
  */
1066
1098
  private async normalizeCloneConfig(repoDir: string, branch: string): Promise<void> {
1099
+ if (await this.isWorktree(repoDir)) {
1100
+ // A worktree reads the repository's one config: the branch's keys are
1101
+ // written there, one writer at a time, and the credential helper is the
1102
+ // repository's, stamped once for every worktree.
1103
+ try {
1104
+ await this.repository.writeConfig(repoDir, cloneTrackingConfigArgs(branch));
1105
+ } catch (err) {
1106
+ log.warn(`could not normalize the config of the "${branch}" worktree:`, {
1107
+ detail: redactError(err, this.gitRunner.credentials.token()),
1108
+ });
1109
+ }
1110
+ await this.repository.refreshCredentials();
1111
+ return;
1112
+ }
1067
1113
  try {
1068
1114
  for (const args of cloneTrackingConfigArgs(branch)) {
1069
1115
  await this.gitRunner.run(repoDir, args);
@@ -1156,9 +1202,31 @@ export class WorkspaceService implements IWorkspaceService {
1156
1202
  */
1157
1203
  private async refreshCredentialHelperIfStale(branch: string, repoDir: string): Promise<void> {
1158
1204
  if (this.stampedCredentialFingerprint.get(branch) === this.credentialFingerprint()) return;
1205
+ if (await this.isWorktree(repoDir)) {
1206
+ // The repository's helper serves every worktree; it keeps its own
1207
+ // fingerprint, and a no-op costs no git.
1208
+ await this.repository.refreshCredentials();
1209
+ return;
1210
+ }
1159
1211
  await this.stampCredentialHelper(repoDir, branch);
1160
1212
  }
1161
1213
 
1214
+ /** The workspace ids whose checkout folder is a worktree (sound or broken) — a disk scan. */
1215
+ private async worktreeIdsOnDisk(): Promise<string[]> {
1216
+ let entries: Array<{ name: string; isDirectory: () => boolean }>;
1217
+ try {
1218
+ entries = await fs.readdir(this.workspacesRoot, { withFileTypes: true });
1219
+ } catch {
1220
+ return [];
1221
+ }
1222
+ const ids: string[] = [];
1223
+ for (const entry of entries) {
1224
+ if (!entry.isDirectory()) continue;
1225
+ if (await this.isWorktree(path.join(this.workspacesRoot, entry.name, this.kbDirName))) ids.push(entry.name);
1226
+ }
1227
+ return ids;
1228
+ }
1229
+
1162
1230
  private async cloneProcessMapForBranch(workspaceDir: string, branch: string): Promise<void> {
1163
1231
  const targetDir = path.join(workspaceDir, this.kbDirName);
1164
1232
 
@@ -1187,16 +1255,14 @@ export class WorkspaceService implements IWorkspaceService {
1187
1255
 
1188
1256
  if (target !== null) {
1189
1257
  // The directory exists, but a previous bootstrap may have crashed
1190
- // mid-clone — leaving a directory shell without `.git`. Treat
1191
- // anything missing `.git` as not-cloned and re-clone. (Can't be
1192
- // an active workspace either: every cloned repo has `.git` from
1193
- // step one.)
1194
- let alreadyCloned = false;
1258
+ // midway — leaving a directory shell without `.git` — or it is a
1259
+ // worktree whose link to the repository is broken (the repository was
1260
+ // lost, or the worktree pruned from under it). Either is an unfinished
1261
+ // checkout: wipe it and add the worktree again. A working worktree, or a
1262
+ // full clone from an earlier release, is adopted as it is.
1263
+ let shape;
1195
1264
  try {
1196
- const dotGit = await fs.stat(path.join(targetDir, '.git'));
1197
- if (dotGit.isDirectory() || dotGit.isFile()) {
1198
- alreadyCloned = true;
1199
- }
1265
+ shape = await workingCopyShape(targetDir);
1200
1266
  } catch (err) {
1201
1267
  // Only treat "does-not-exist" as "half-built dir, wipe and
1202
1268
  // re-clone". A transient EACCES / EIO / EBUSY must NOT delete
@@ -1205,8 +1271,9 @@ export class WorkspaceService implements IWorkspaceService {
1205
1271
  // (`targetDir` is known to be a directory here, so ENOTDIR can only
1206
1272
  // mean `.git` itself vanished under us — an absence either way.)
1207
1273
  if (!isAbsence(err)) throw err;
1274
+ shape = 'none' as const;
1208
1275
  }
1209
- if (alreadyCloned) {
1276
+ if (shape === 'worktree' || shape === 'clone') {
1210
1277
  // We don't auto-pull because that could clobber another user's
1211
1278
  // in-progress lock-held edits. The CONFIG is re-stamped though — it
1212
1279
  // never touches the working tree, and a clone with drifted tracking
@@ -1214,31 +1281,25 @@ export class WorkspaceService implements IWorkspaceService {
1214
1281
  await this.normalizeCloneConfig(targetDir, branch);
1215
1282
  return;
1216
1283
  }
1217
- // Half-built dir — wipe and re-clone.
1284
+ if (shape === 'broken') {
1285
+ log.warn(`the "${branch}" worktree had lost its link to the repository; it is added again.`);
1286
+ }
1287
+ // Half-built dir — wipe and add the worktree again.
1218
1288
  await fs.rm(targetDir, { recursive: true, force: true });
1219
1289
  }
1220
1290
 
1221
- // Borrow an existing sibling clone's objects to skip the bulk download.
1222
- // Resolved before the clone so a failure mid-clone still rolls back cleanly.
1223
- const reference = await this.findSiblingRepo(branch);
1224
1291
  try {
1225
- await this.runClone(targetDir, branch, reference);
1226
- // Persist longpaths in the cloned repo's config so subsequent
1227
- // checkouts also honor it.
1228
- await this.gitRunner.run(targetDir, ['config', 'core.longpaths', 'true']);
1229
- // Generic bot identity for the clone — every workflow commit
1230
- // overrides via `--author=…` so the real human shows up in
1231
- // `git log`. This is purely the fallback committer.
1232
- await this.gitRunner.run(targetDir, ['config', 'user.name', BOT_NAME]);
1233
- await this.gitRunner.run(targetDir, ['config', 'user.email', BOT_EMAIL]);
1234
- // Pin the clone to exactly one fetch refspec and one upstream ref for
1235
- // this branch. `git clone -b` already produces that shape; stamping it
1236
- // explicitly means the shape is asserted rather than assumed, and the
1237
- // same call is what repairs an existing clone that drifted.
1238
- await this.normalizeCloneConfig(targetDir, branch);
1239
- log.info(`Cloned ${this.kbDirName} for branch "${branch}"` + (reference ? ' (referenced a sibling clone)' : ''));
1240
- // A fresh clone has already downloaded every ref — tell the git layer
1241
- // so the first `listBranches` skips the redundant implicit `git fetch`.
1292
+ // A worktree of the one repository: no history is copied. The
1293
+ // repository is created and fetched first if this is the first branch
1294
+ // opened on the deployment, and the identity, the long-paths setting,
1295
+ // the credential helper and the branch's tracking config are the
1296
+ // repository's (see `KbRepository`). The add asks the remote for the
1297
+ // branch, so the worktree starts at its tip as a fresh clone would.
1298
+ await this.repository.addWorktree(branch, targetDir);
1299
+ log.info(`Added the ${this.kbDirName} worktree for branch "${branch}"`);
1300
+ // A working copy was just made — tell the git layer, so a fetch record
1301
+ // it kept for this workspace id (a clone it replaced) is not read as
1302
+ // this one's, and the first `listBranches` skips a redundant fetch.
1242
1303
  // Isolated from the clone-rollback `catch` below: a misbehaving listener
1243
1304
  // must not cause a successful clone to be wiped.
1244
1305
  try {
@@ -1252,6 +1313,7 @@ export class WorkspaceService implements IWorkspaceService {
1252
1313
  const redacted = redactError(err, this.gitRunner.credentials.token());
1253
1314
  // Roll back partial state so the next bootstrap retries cleanly.
1254
1315
  await fs.rm(targetDir, { recursive: true, force: true }).catch(() => {});
1316
+ await this.repository.prune().catch(() => {});
1255
1317
  // A branch origin does not have is a fact about the branch, not a
1256
1318
  // failure of ours — but WHICH fact depends on whether the platform ever
1257
1319
  // knew the name. A branch it cloned or listed and origin no longer has
@@ -1299,8 +1361,13 @@ export class WorkspaceService implements IWorkspaceService {
1299
1361
  */
1300
1362
  async deleteWorkspace(workspaceId: string): Promise<void> {
1301
1363
  const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
1302
- await fs.rm(workspaceDir, { recursive: true, force: true });
1303
1364
  const branch = branchForWorkspaceId(workspaceId);
1365
+ const repoDir = path.join(workspaceDir, this.kbDirName);
1366
+ // A worktree is removed through git and pruned from the repository, with
1367
+ // its branch's local ref; the repository's objects and every other
1368
+ // worktree are untouched.
1369
+ if (await this.isWorktree(repoDir)) await this.repository.removeWorktree(repoDir, branch);
1370
+ await fs.rm(workspaceDir, { recursive: true, force: true });
1304
1371
  this.branchDirs.delete(branch);
1305
1372
  }
1306
1373
 
@@ -1657,6 +1724,15 @@ export class WorkspaceService implements IWorkspaceService {
1657
1724
  ): Promise<void> {
1658
1725
  const workspaceDir = await this.resolveWorkspaceDir(workspaceId);
1659
1726
  const repoDir = path.join(workspaceDir, this.kbDirName);
1727
+ if (await this.isWorktree(repoDir)) {
1728
+ // ONE fetch for every workspace: the repository's, throttled and joined
1729
+ // there.
1730
+ const ok = await this.repository.fetch({ maxAgeMs: FETCH_CACHE_TTL_MS, force: opts.force });
1731
+ if (opts.strict && !ok) throw new Error('git fetch origin failed — remote refs could not be refreshed');
1732
+ return;
1733
+ }
1734
+ // A full clone from an earlier release fetches on its own until the boot
1735
+ // phase converts it.
1660
1736
  const now = Date.now();
1661
1737
  // The TTL is stamped only by a SUCCESSFUL fetch, so a fresh window is
1662
1738
  // itself the proof a strict caller wants.
@@ -2356,12 +2432,7 @@ export class WorkspaceService implements IWorkspaceService {
2356
2432
  } catch {
2357
2433
  return false;
2358
2434
  }
2359
- try {
2360
- await fs.access(path.join(workspaceDir, this.kbDirName, '.git'));
2361
- return true;
2362
- } catch {
2363
- return false;
2364
- }
2435
+ return this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false);
2365
2436
  }
2366
2437
 
2367
2438
  /**
@@ -2489,6 +2560,14 @@ export class WorkspaceService implements IWorkspaceService {
2489
2560
  log.warn(`sweep failed for ${entry.name}:`, { err });
2490
2561
  }
2491
2562
  }
2563
+ // The repository is not under the root, so the sweep never touched it.
2564
+ // What it forgets is the worktrees whose folders went, and their
2565
+ // branches' local refs, as removing a clone took its branch with it.
2566
+ if (removed.length > 0) {
2567
+ await this.repository
2568
+ .forgetWorktrees(removed.map((id) => branchForWorkspaceId(id)))
2569
+ .catch((err: unknown) => log.warn('could not prune the swept worktrees from the repository:', { err }));
2570
+ }
2492
2571
  return { removed };
2493
2572
  }
2494
2573
 
@@ -2550,12 +2629,11 @@ export class WorkspaceService implements IWorkspaceService {
2550
2629
  return this.branchDirs.get(branch)!;
2551
2630
  }
2552
2631
  const workspaceDir = this.workspaceDirWithinRoot(workspaceId);
2553
- try {
2554
- await fs.access(path.join(workspaceDir, this.kbDirName, '.git'));
2632
+ // A worktree whose link to the repository is broken falls through too:
2633
+ // the bootstrap recreates it.
2634
+ if (await this.hasWorkingCopy(path.join(workspaceDir, this.kbDirName)).catch(() => false)) {
2555
2635
  this.registerBranchDir(branch, workspaceDir);
2556
2636
  return workspaceDir;
2557
- } catch {
2558
- // Fall through to lazy bootstrap.
2559
2637
  }
2560
2638
  try {
2561
2639
  await this.getOrCreateForBranch(branch);