@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
@@ -8,7 +8,11 @@ import type {
8
8
  BranchInfo,
9
9
  ChangedPathPair,
10
10
  CommitAttribution,
11
+ ConflictedFile,
11
12
  IGitService,
13
+ MergeIntoBranchOutcome,
14
+ MergeResolution,
15
+ ReservedPushOps,
12
16
  RemoteSyncPullResult,
13
17
  PrFileStatus,
14
18
  PullRequestFile,
@@ -21,10 +25,14 @@ import type { WorkspaceService } from '../../workspace/workspace.service.js';
21
25
  import type { WorkflowHooks, CommitValidationContext } from '../workflow-hooks.js';
22
26
  import type { IAccessControl } from '../../access/access-control.interface.js';
23
27
  import { AccessDeniedError } from '../../access-model/access-errors.js';
24
- import { WorkspaceMutex } from '../../kb-fs/mutex.js';
28
+ import { WorkspaceIdMutex, type WorkspaceMutex } from '../../kb-fs/index.js';
25
29
  import { isAbsence } from '../../../shared/fs.contract.js';
26
30
  import { assertInsideRepo } from '../../kb-fs/repo-path.js';
27
31
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
32
+ import { readRefSha } from '../../kb-fs/index.js';
33
+ import { utf8Text } from '../../../shared/utf8-text.js';
34
+ import { lstatOrNull } from '../../../shared/rename-no-replace.js';
35
+ import { linkedAncestor } from '../../../shared/no-link-below.js';
28
36
  import {
29
37
  assertValidBranchName,
30
38
  assertValidRelativePath,
@@ -47,7 +55,6 @@ import {
47
55
  import {
48
56
  GitRunError,
49
57
  assertOneSpecPerLine,
50
- isGitTimeout,
51
58
  redactGitToken,
52
59
  type GitCredentials,
53
60
  type GitRunOptions,
@@ -294,6 +301,25 @@ export class GitService implements IGitService {
294
301
  // counts, calm under large branch lists.
295
302
  private static readonly MAX_AHEAD_BEHIND_CONCURRENCY = 8;
296
303
 
304
+ /**
305
+ * A change request's changed entries, keyed by the two commits its diff
306
+ * was read between. Two commits never change, and neither does the
307
+ * merge-base between them, so the three-dot diff of a pair is a fact: a
308
+ * push moves a tip, the pair changes, the old entry is simply never asked
309
+ * for again. No TTL, no invalidation.
310
+ *
311
+ * This is what a change-request listing was paying git for on every read:
312
+ * one `diff` per open request, polled every minute and refetched on every
313
+ * event, where nothing had moved. Now git is asked once per pair, which is
314
+ * once per push. Keyed by commits alone, not by workspace: every clone of
315
+ * the knowledge base holds the same objects, so the answer is the same in
316
+ * all of them. Bounded FIFO; the bound only caps memory.
317
+ */
318
+ private readonly changedEntriesByCommits = new Map<string, NameStatusEntry[]>();
319
+ /** Diffs being read right now, so concurrent misses on one pair share one `git diff`. */
320
+ private readonly changedEntriesInFlight = new Map<string, Promise<NameStatusEntry[]>>();
321
+ private static readonly CHANGED_ENTRIES_MAX = 4096;
322
+
297
323
  constructor(
298
324
  private readonly workspaceService: WorkspaceService,
299
325
  /**
@@ -305,7 +331,10 @@ export class GitService implements IGitService {
305
331
  */
306
332
  private readonly hooks: WorkflowHooks,
307
333
  private readonly kb: KbContext,
308
- private readonly mutex: WorkspaceMutex = new WorkspaceMutex(),
334
+ // Keyed by the CANONICAL workspace id: a route's Express-decoded `:id`
335
+ // (`alice/feature`) and a tool's `alice%2Ffeature` name one clone, and
336
+ // must take one reservation.
337
+ private readonly mutex: WorkspaceMutex = new WorkspaceIdMutex(),
309
338
  private readonly accessControl: IAccessControl | null = null,
310
339
  /**
311
340
  * How git is actually run — see `shared/git.contract.ts`. The composition
@@ -378,7 +407,9 @@ export class GitService implements IGitService {
378
407
  * above rather than kept separately, so it cannot disagree with it.
379
408
  */
380
409
  lastRemoteContact(): { at: number; ok: boolean } | null {
381
- let latest: { at: number; ok: boolean } | null = null;
410
+ // The repository's fetches are every worktree's; the per-workspace record
411
+ // is a full clone's.
412
+ let latest: { at: number; ok: boolean } | null = this.workspaceService.repository?.lastContact() ?? null;
382
413
  for (const [workspaceId, at] of this.lastImplicitFetchAt) {
383
414
  if (latest === null || at > latest.at) {
384
415
  latest = { at, ok: this.lastImplicitFetchOk.get(workspaceId) ?? false };
@@ -495,12 +526,27 @@ export class GitService implements IGitService {
495
526
  // Union local heads with origin's remote-tracking refs: a fresh per-user
496
527
  // clone only has one local head (HEAD's default), so without this we'd hide
497
528
  // target-company-state and every draft pushed by another user.
529
+ //
530
+ // The workspace is a worktree of the one repository, which holds every
531
+ // OTHER worktree's local branch too. Those are not this workspace's: a
532
+ // clone never had them. A local head checked out in another worktree
533
+ // is read as that branch's remote-tracking ref alone (and not at all
534
+ // once origin no longer has it), so a branch deleted on origin is not
535
+ // kept alive by its own worktree's local ref — the orphan sweep reads
536
+ // this list. A branch is checked out in at most one worktree, so "not
537
+ // this one's HEAD" is "another's".
498
538
  const { stdout } = await this.git(cwd, [
499
539
  'for-each-ref',
500
- '--format=%(refname:short)%09%(refname)',
540
+ '--format=%(refname:short)%09%(refname)%09%(worktreepath)',
501
541
  'refs/heads',
502
542
  'refs/remotes/origin',
503
543
  ]);
544
+ const ownBranch = (
545
+ await this.git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(
546
+ (r) => r.stdout,
547
+ () => '',
548
+ )
549
+ ).trim();
504
550
 
505
551
  type Row = { shortName: string; ref: string; hasLocal: boolean; hasRemote: boolean };
506
552
  const buckets = new Map<string, Row>();
@@ -510,7 +556,7 @@ export class GitService implements IGitService {
510
556
  const tab = trimmed.indexOf('\t');
511
557
  if (tab < 0) continue;
512
558
  const rawShort = trimmed.slice(0, tab);
513
- const ref = trimmed.slice(tab + 1);
559
+ const [ref = '', worktreePath = ''] = trimmed.slice(tab + 1).split('\t');
514
560
  if (!rawShort || !ref) continue;
515
561
  // Skip symbolic refs up-front. Different git versions report
516
562
  // `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
@@ -521,6 +567,7 @@ export class GitService implements IGitService {
521
567
  const isLocal = ref.startsWith('refs/heads/');
522
568
  const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
523
569
  if (!shortName) continue;
570
+ if (isLocal && worktreePath !== '' && shortName !== ownBranch) continue;
524
571
  // Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
525
572
  // if a future git version lands us here, don't bucket it as a branch.
526
573
  if (shortName === 'HEAD') continue;
@@ -614,6 +661,20 @@ export class GitService implements IGitService {
614
661
  workspaceId: string,
615
662
  force = false,
616
663
  ): Promise<boolean> {
664
+ // A worktree's remote-tracking refs are the repository's: ONE fetch for
665
+ // every workspace, throttled, joined and reserved there. The per-workspace
666
+ // record below is for a full clone from an earlier release, which fetches
667
+ // on its own until the boot phase converts it.
668
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
669
+ if (repository) {
670
+ return repository.fetch({
671
+ maxAgeMs: GitService.IMPLICIT_FETCH_TTL_MS,
672
+ // A host that is down is not asked again by every poll inside the
673
+ // window, as the per-workspace record below stamps a failure too.
674
+ failureBackoffMs: GitService.IMPLICIT_FETCH_TTL_MS,
675
+ force,
676
+ });
677
+ }
617
678
  const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
618
679
  if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
619
680
  return this.lastImplicitFetchOk.get(workspaceId) ?? true;
@@ -692,8 +753,9 @@ export class GitService implements IGitService {
692
753
  // navigation to the new draft's URL fails with
693
754
  // "Remote branch <name> not found in upstream origin". Roll the local
694
755
  // ref back on failure so a retry isn't blocked by "branch already exists".
756
+ // No `-u`: the local head is dropped right below, and its upstream with it.
695
757
  try {
696
- await this.git(cwd, ['push', '-u', 'origin', name]);
758
+ await this.git(cwd, ['push', 'origin', name]);
697
759
  } catch (err) {
698
760
  await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
699
761
  throw err;
@@ -829,7 +891,14 @@ export class GitService implements IGitService {
829
891
  let localTip: string | null = null;
830
892
  if (await this.refExists(cwd, `refs/heads/${name}`)) {
831
893
  localTip = await this.revParseOrNull(cwd, `refs/heads/${name}`);
832
- await this.git(cwd, ['branch', '-D', name]);
894
+ await this.git(cwd, ['branch', '-D', name]).catch((err: unknown) => {
895
+ // In a repository of worktrees the branch's local ref is shared, and
896
+ // git keeps it while the branch's own workspace has it checked out.
897
+ // Removing that workspace — which the caller does next — takes the
898
+ // worktree and the ref with it.
899
+ if (/checked out at|used by worktree/i.test(err instanceof Error ? err.message : String(err))) return;
900
+ throw err;
901
+ });
833
902
  }
834
903
  return { lastCommit: remoteTip ?? localTip };
835
904
  });
@@ -903,16 +972,18 @@ export class GitService implements IGitService {
903
972
  async isAncestor(workspaceId: string, ancestor: string, descendant: string): Promise<boolean> {
904
973
  const sha = /^[0-9a-f]{4,64}$/i;
905
974
  if (!sha.test(ancestor) || !sha.test(descendant)) throw new Error('isAncestor takes commit ids');
906
- return this.mutex.run(workspaceId, async () => {
907
- const cwd = await this.repoDir(workspaceId);
908
- try {
909
- await this.git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]);
910
- return true;
911
- } catch (err) {
912
- if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1) return false;
913
- throw err;
914
- }
915
- });
975
+ return this.mutex.run(workspaceId, async () => this.isAncestorUnlocked(await this.repoDir(workspaceId), ancestor, descendant));
976
+ }
977
+
978
+ /** `isAncestor`'s body, for a caller that already holds the workspace's reservation. */
979
+ private async isAncestorUnlocked(cwd: string, ancestor: string, descendant: string): Promise<boolean> {
980
+ try {
981
+ await this.git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]);
982
+ return true;
983
+ } catch (err) {
984
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1) return false;
985
+ throw err;
986
+ }
916
987
  }
917
988
 
918
989
  /**
@@ -1409,7 +1480,25 @@ export class GitService implements IGitService {
1409
1480
  );
1410
1481
  }
1411
1482
 
1412
- await this.git(cwd, ['push', '-u', 'origin', branch]);
1483
+ // No `-u`: it writes the upstream into the config during the push, and
1484
+ // a worktree's config is the repository's, shared by every workspace —
1485
+ // git refuses a second writer of it rather than waiting. The upstream is
1486
+ // stamped after the push instead, as one short write made as the
1487
+ // repository's administrator (see `git`).
1488
+ await this.git(cwd, ['push', 'origin', branch]);
1489
+ // The stamp is maintenance after a push that LANDED: a config the
1490
+ // repository's administrator could not write just now (a lock held by
1491
+ // another worktree's stamp) does not make the branch unpublished, and
1492
+ // reporting it as a refused push would hand a published commit to
1493
+ // recovery. Logged; the next push stamps it.
1494
+ try {
1495
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
1496
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
1497
+ } catch (err) {
1498
+ log.warn(`"${branch}" is pushed; its tracking config could not be stamped after the push:`, {
1499
+ detail: redactGitToken(err instanceof Error ? err.message : String(err), this.credentials.token()),
1500
+ });
1501
+ }
1413
1502
  }
1414
1503
  }
1415
1504
 
@@ -1454,73 +1543,438 @@ export class GitService implements IGitService {
1454
1543
  assertValidBranchName(sourceBranch);
1455
1544
  assertValidBranchName(targetBranch);
1456
1545
  assertValidAuthor(user);
1546
+ // The one merge protocol (`mergeRefIntoCheckout`), asked for what the
1547
+ // auto-merge needs: no resolutions, the conflicted paths alone, no
1548
+ // publish. Under the per-branch workspace model the workspace IS the
1549
+ // source branch, so the checked-out guard is not asked for; nor is the
1550
+ // dirty-tree one — save=share keeps the tree clean before an auto-merge,
1551
+ // and a dirty one is an upstream bug `statusInternal` already logs.
1457
1552
  return this.mutex.run(workspaceId, async () => {
1458
- const cwd = await this.repoDir(workspaceId);
1459
- // Under the per-branch workspace model, the workspace's identity is
1460
- // the source branch — the legacy "current branch must equal sourceBranch"
1461
- // guard was an artifact of the single-workspace-many-branches model and
1462
- // is no longer needed. The legacy dirty-tree refusal is also gone:
1463
- // save=share guarantees the tree is clean before any auto-merge runs,
1464
- // and the only way it could be dirty now is an upstream bug, which
1465
- // `statusInternal` already logs server-side.
1466
- // Refresh the target ref so we merge against the latest published
1467
- // state. Failing the fetch here is non-recoverable for this
1468
- // operation: a stale `origin/<target>` would let us silently miss
1469
- // conflicts the next push would surface, and the whole point of
1470
- // `mergeFromOrigin` is to surface those conflicts now. Propagate
1471
- // the error so the auto-merge aborts cleanly instead of producing
1472
- // a merge based on a stale ref. Explicit destination refspec for the
1473
- // same reason as `mergeChangeRequest`: a bare `fetch origin <branch>`
1474
- // updates `origin/<branch>` only as far as `remote.origin.fetch` allows,
1475
- // and a drifted refspec would hand this merge the stale ref it is
1476
- // explicitly trying to avoid.
1477
- await this.git(cwd, [
1478
- 'fetch', '--no-write-fetch-head', 'origin',
1479
- `+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
1480
- ]);
1553
+ const outcome = await this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), sourceBranch, targetBranch, user, {
1554
+ subject: `Refresh ${sourceBranch} from ${targetBranch}`,
1555
+ body:
1556
+ `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
1557
+ `as part of opening or refreshing a change request. ` +
1558
+ `Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
1559
+ `none were detected, so the merge applied cleanly.`,
1560
+ resolutions: new Map(),
1561
+ guardCheckout: false,
1562
+ describeConflicts: false,
1563
+ });
1564
+ if (outcome.kind === 'conflicts') return { kind: 'conflicts' as const, paths: outcome.unresolved };
1565
+ return { kind: 'clean' as const, alreadyUpToDate: outcome.kind === 'nothing-to-merge' };
1566
+ });
1567
+ }
1568
+
1569
+ /** The most of one side of a conflicted file that travels in an answer; past it the side is read by the caller instead. */
1570
+ private static readonly CONFLICT_SIDE_MAX_CHARS = 200_000;
1571
+
1572
+ async mergeIntoBranch(
1573
+ workspaceId: string,
1574
+ branch: string,
1575
+ fromBranch: string,
1576
+ user: AuthUser,
1577
+ opts: {
1578
+ subject: string;
1579
+ body: string;
1580
+ resolutions?: MergeResolution[];
1581
+ publish?: (ops: ReservedPushOps) => Promise<void>;
1582
+ authorizeResolutions?: (merge: { ours: string; theirs: string }) => Promise<void>;
1583
+ },
1584
+ ): Promise<MergeIntoBranchOutcome> {
1585
+ assertValidBranchName(branch);
1586
+ assertValidBranchName(fromBranch);
1587
+ assertValidAuthor(user);
1588
+ if (/\n\s*\n/.test(opts.subject)) {
1589
+ throw new WorkflowValidationError('merge commit subject must be a single paragraph');
1590
+ }
1591
+ // Every resolution names a repository path; which of them are conflicted
1592
+ // is only known inside the merge, where they are matched exactly.
1593
+ const resolutions = new Map<string, MergeResolution>();
1594
+ // A resolution writes a file on the branch: it is somebody's to
1595
+ // authorize, and a caller that brings none writes nothing (closed).
1596
+ if ((opts.resolutions?.length ?? 0) > 0 && typeof opts.authorizeResolutions !== 'function') {
1597
+ throw new Error('mergeIntoBranch: resolutions need an `authorizeResolutions` to be asked inside the merge');
1598
+ }
1599
+ for (const r of opts.resolutions ?? []) {
1600
+ assertValidRelativePath(r.path);
1601
+ const p = this.stripRepoPrefix(r.path);
1602
+ const ways = [r.content !== undefined, r.choose !== undefined].filter(Boolean).length;
1603
+ if (ways !== 1) {
1604
+ throw new WorkflowValidationError(`resolution for "${p}": give either \`content\` or \`choose\`, not ${ways === 0 ? 'neither' : 'both'}`);
1605
+ }
1606
+ if (r.choose !== undefined && !['ours', 'theirs', 'delete'].includes(r.choose)) {
1607
+ throw new WorkflowValidationError(`resolution for "${p}": \`choose\` is \`ours\`, \`theirs\` or \`delete\``);
1608
+ }
1609
+ if (resolutions.has(p)) throw new WorkflowValidationError(`"${p}" is resolved twice`);
1610
+ resolutions.set(p, { ...r, path: p });
1611
+ }
1612
+ return this.mutex.run(workspaceId, async () =>
1613
+ this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), branch, fromBranch, user, {
1614
+ subject: opts.subject,
1615
+ body: opts.body,
1616
+ resolutions,
1617
+ guardCheckout: true,
1618
+ describeConflicts: true,
1619
+ publish: opts.publish,
1620
+ authorizeResolutions: opts.authorizeResolutions,
1621
+ }),
1622
+ );
1623
+ }
1624
+
1625
+ /**
1626
+ * THE merge of `origin/<fromBranch>` into the checkout at `cwd` — what
1627
+ * `mergeFromOrigin` (the change-request auto-merge) and `mergeIntoBranch`
1628
+ * (the agent's update) both run. The caller holds the workspace's
1629
+ * reservation. `--no-commit --no-ff`, so the commit is authored as `user`
1630
+ * and the record of what was merged is never fast-forwarded past.
1631
+ *
1632
+ * The checkout is never left mid-merge, whatever happens: every exit after
1633
+ * `git merge` has started — a conflict described, a resolution refused or
1634
+ * failed, a commit or a publish that failed — runs `merge --abort` first,
1635
+ * and an abort that itself fails is reported, never swallowed, because a
1636
+ * checkout stuck in a merge is what breaks the commit worker and every
1637
+ * later operation on the branch.
1638
+ *
1639
+ * `publish` runs over the merge commit inside this same reservation, so no
1640
+ * save or sync can land between the commit and its push; it is the
1641
+ * caller's push policy (the workflow's `pushWithRecovery`: one replay onto
1642
+ * the moved branch with the merge kept, `--rebase-merges`, then the typed
1643
+ * hand-off) over this checkout's own unlocked moves.
1644
+ */
1645
+ private async mergeRefIntoCheckout(
1646
+ workspaceId: string,
1647
+ cwd: string,
1648
+ branch: string,
1649
+ fromBranch: string,
1650
+ user: AuthUser,
1651
+ opts: {
1652
+ subject: string;
1653
+ body: string;
1654
+ resolutions: Map<string, MergeResolution>;
1655
+ /** Refuse a checkout that is not on `branch`, or holds uncommitted edits. */
1656
+ guardCheckout: boolean;
1657
+ /** Carry every conflicted file's sides in the answer; else the paths alone. */
1658
+ describeConflicts: boolean;
1659
+ /** Run over the merge commit inside the reservation, with the pushes it may make. */
1660
+ publish?: (ops: ReservedPushOps) => Promise<void>;
1661
+ /**
1662
+ * Decide whether the caller may settle the paths the resolutions name,
1663
+ * given the two commits the merge is actually between — asked INSIDE
1664
+ * the reservation, after the fetch and BEFORE the merge touches the
1665
+ * checkout: the source rules read are those of the commit being
1666
+ * merged, not of a tracking ref from before it moved, and the
1667
+ * checkout's are the branch's own commit (`ours`), clean — never a
1668
+ * tree mid-merge, where a rule file's conflict markers or merged
1669
+ * content would decide. Throwing refuses before anything is written.
1670
+ */
1671
+ authorizeResolutions?: (merge: { ours: string; theirs: string }) => Promise<void>;
1672
+ },
1673
+ ): Promise<MergeIntoBranchOutcome> {
1674
+ const theirsRef = `refs/remotes/origin/${fromBranch}`;
1675
+ if (opts.guardCheckout) {
1676
+ const checkedOut = await this.currentBranch(cwd);
1677
+ if (checkedOut !== branch) {
1678
+ throw new WorkflowValidationError(`the workspace is on "${checkedOut}", not on "${branch}"`);
1679
+ }
1680
+ // A merge rewrites tracked files. Edits still on their way to a commit
1681
+ // (a save the worker has not landed) would be swept into the merge
1682
+ // commit under this user's name, or lost under the merge's version.
1683
+ const { stdout: dirty } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
1684
+ if (parsePorcelainZ(dirty).length > 0) {
1685
+ throw new WorkflowDomainError(
1686
+ `"${branch}" has edits that are not committed yet. Try again once they are saved.`,
1687
+ 409,
1688
+ { kind: 'branch-busy', branch },
1689
+ );
1690
+ }
1691
+ }
1692
+ // `git merge`/`git commit` need a committer identity (see `mergeChangeRequest`).
1693
+ await this.git(cwd, ['config', 'user.name', BOT_NAME]);
1694
+ await this.git(cwd, ['config', 'user.email', BOT_EMAIL]);
1695
+ // The published tip, explicitly: a bare `fetch origin <branch>` updates
1696
+ // the tracking ref only as far as `remote.origin.fetch` allows, and a
1697
+ // stale ref would hide exactly the conflicts this merge exists to surface.
1698
+ await this.git(cwd, ['fetch', '--no-write-fetch-head', 'origin', `+refs/heads/${fromBranch}:${theirsRef}`]);
1699
+ const ours = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
1700
+ // The COMMIT the merge is of, from here on — never the ref it was read
1701
+ // from: the repository's refs are shared by every worktree and moved by
1702
+ // every fetch (the timer's sync, another workspace's merge), so what is
1703
+ // authorized, merged, described and recorded is this one sha.
1704
+ const theirs = (await this.git(cwd, ['rev-parse', theirsRef])).stdout.trim();
1705
+ // Resolutions are authorized here, on the clean checkout at `ours` with
1706
+ // `theirs` fetched, before the merge changes a single file. Which of
1707
+ // them are conflicted is matched after the merge; a stray one is refused.
1708
+ if (opts.resolutions.size > 0 && opts.authorizeResolutions) await opts.authorizeResolutions({ ours, theirs });
1709
+ // A merge with nothing to settle leaves no resolution a place to go: one
1710
+ // supplied anyway names a path that is not conflicted, and is refused as
1711
+ // such rather than dropped on the floor behind a published merge.
1712
+ const noConflictsFor = (): WorkflowValidationError =>
1713
+ new WorkflowValidationError(
1714
+ `nothing conflicted, so nothing is resolved here: ${[...opts.resolutions.keys()].join(', ')}.`,
1715
+ { kind: 'not-conflicted', paths: [...opts.resolutions.keys()] },
1716
+ );
1717
+ // Nothing to merge is a question of ANCESTRY, not of content: a source
1718
+ // the branch already reaches is merged; one it does not reach is merged
1719
+ // in even when the two trees are identical, so the branch records that
1720
+ // it holds the source's history and stops reading as behind it.
1721
+ if (await this.isAncestorUnlocked(cwd, theirs, ours)) {
1722
+ if (opts.resolutions.size > 0) throw noConflictsFor();
1723
+ return { kind: 'nothing-to-merge', sha: ours, from: fromBranch };
1724
+ }
1725
+
1726
+ try {
1727
+ await this.git(cwd, ['merge', '--no-commit', '--no-ff', theirs]);
1728
+ } catch (err) {
1729
+ // A conflict, or another failure: either way the merge may be on disk
1730
+ // from here on, and every exit below undoes it.
1731
+ return this.settleMerge(cwd, async () => {
1732
+ const conflicted = await this.conflictedPaths(cwd);
1733
+ if (conflicted.length === 0) {
1734
+ throw new Error(
1735
+ `git merge failed without detectable conflicts: ${redactGitToken(
1736
+ err instanceof Error ? err.message : String(err),
1737
+ this.credentials.token(),
1738
+ )}`,
1739
+ );
1740
+ }
1741
+ const stray = [...opts.resolutions.keys()].filter((p) => !conflicted.includes(p));
1742
+ if (stray.length > 0) {
1743
+ // Only a conflicted path is written by a resolution: anything else
1744
+ // is an ordinary write, which goes through the file tools and their
1745
+ // locks. Refused whole, before any resolution is applied — naming
1746
+ // the caller's own paths only, never the conflicted set (which the
1747
+ // caller is told of, cut to what they may read, by the answer).
1748
+ throw new WorkflowValidationError(
1749
+ `not conflicted, so not resolved here: ${stray.join(', ')}.`,
1750
+ { kind: 'not-conflicted', paths: stray },
1751
+ );
1752
+ }
1753
+ const unresolved = conflicted.filter((p) => !opts.resolutions.has(p));
1754
+ if (unresolved.length > 0) {
1755
+ // Described while the merge is on disk — the three stages and the
1756
+ // marked-up working copy exist only then — and undone (by
1757
+ // `settleMerge`) before the answer leaves the reservation.
1758
+ const mergeBase = await this.git(cwd, ['merge-base', ours, theirs])
1759
+ .then((r) => r.stdout.trim() || null)
1760
+ .catch(() => null);
1761
+ const files: ConflictedFile[] = [];
1762
+ if (opts.describeConflicts) for (const p of conflicted) files.push(await this.describeConflict(cwd, p));
1763
+ return { done: false, outcome: { kind: 'conflicts', from: fromBranch, mergeBase, ours, theirs, files, unresolved } };
1764
+ }
1765
+ for (const p of conflicted) await this.applyResolution(cwd, opts.resolutions.get(p)!);
1766
+ const still = await this.conflictedPaths(cwd);
1767
+ if (still.length > 0) throw new Error(`still conflicted after the resolutions were applied: ${still.join(', ')}`);
1768
+ return {
1769
+ done: true,
1770
+ outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, conflicted),
1771
+ };
1772
+ });
1773
+ }
1774
+ // A clean merge: committed whatever it staged — an empty diff (the two
1775
+ // sides made the same change) is still the source's history joined.
1776
+ return this.settleMerge(cwd, async () => {
1777
+ if (opts.resolutions.size > 0) throw noConflictsFor();
1778
+ return { done: true, outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, []) };
1779
+ });
1780
+ }
1781
+
1782
+
1783
+ /**
1784
+ * Run `work` while a merge may be on disk, and leave none: an answer with
1785
+ * `done: false` (a conflict described, nothing to merge) is aborted before
1786
+ * it is returned, and a failure anywhere in `work` is aborted before it is
1787
+ * rethrown. A commit that landed (`done: true`) needs no abort. An abort
1788
+ * that fails while a merge is still in progress is the one thing never
1789
+ * swallowed: it is reported with the failure it followed.
1790
+ */
1791
+ private async settleMerge(
1792
+ cwd: string,
1793
+ work: () => Promise<{ done: boolean; outcome: MergeIntoBranchOutcome }>,
1794
+ ): Promise<MergeIntoBranchOutcome> {
1795
+ let result: { done: boolean; outcome: MergeIntoBranchOutcome };
1796
+ try {
1797
+ result = await work();
1798
+ } catch (err) {
1799
+ await this.abortMerge(cwd, err);
1800
+ throw err;
1801
+ }
1802
+ if (!result.done) await this.abortMerge(cwd);
1803
+ return result.outcome;
1804
+ }
1805
+
1806
+ /**
1807
+ * `git merge --abort`, and proof that nothing is left: a failed abort with
1808
+ * no `MERGE_HEAD` is the benign "no merge to abort"; one that leaves
1809
+ * `MERGE_HEAD` behind is thrown (wrapping `after`, the failure that led
1810
+ * here, when there is one), because the checkout is now stuck.
1811
+ */
1812
+ private async abortMerge(cwd: string, after?: unknown): Promise<void> {
1813
+ try {
1814
+ await this.git(cwd, ['merge', '--abort']);
1815
+ return;
1816
+ } catch (abortErr) {
1817
+ const merging = await this.refExists(cwd, 'MERGE_HEAD').catch(() => true);
1818
+ if (!merging) return;
1819
+ const detail = redactGitToken(abortErr instanceof Error ? abortErr.message : String(abortErr), this.credentials.token());
1820
+ log.error(`a merge in ${cwd} could not be undone; the checkout is left mid-merge:`, { detail });
1821
+ const lead = after === undefined ? 'the merge could not be undone' : `${after instanceof Error ? after.message : String(after)}; and the merge could not be undone`;
1822
+ throw new Error(`${lead}: ${detail}`, { cause: after ?? abortErr });
1823
+ }
1824
+ }
1825
+
1826
+ /** The merge commit, as `user`, and its publish when asked for. The caller settles a failure. */
1827
+ private async commitMerge(
1828
+ workspaceId: string,
1829
+ cwd: string,
1830
+ branch: string,
1831
+ fromBranch: string,
1832
+ user: AuthUser,
1833
+ opts: { subject: string; body: string; publish?: (ops: ReservedPushOps) => Promise<void> },
1834
+ resolvedPaths: string[],
1835
+ ): Promise<MergeIntoBranchOutcome> {
1836
+ await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', opts.subject, '-m', opts.body]);
1837
+ this.accessControl?.invalidate(workspaceId);
1838
+ // The publish runs here, inside the reservation the merge was made in,
1839
+ // over this checkout's own push and pull-rebase (the merge kept as a
1840
+ // merge) — the caller's policy, the git layer's moves. Whatever it
1841
+ // throws leaves the merge commit on the branch locally; a replay that
1842
+ // conflicts aborts itself (`rebaseOntoRemote`), so the checkout is clean.
1843
+ if (opts.publish) {
1844
+ await opts.publish({
1845
+ push: () => this.pushUnlocked(workspaceId, cwd, user),
1846
+ pullRebase: () => this.pullUnlocked(workspaceId, cwd, { preserveMerges: true }),
1847
+ });
1848
+ }
1849
+ // Read AFTER the publish: a push the host rejected is retried with the
1850
+ // local commits replayed onto the moved branch, which gives the merge
1851
+ // commit a new id — the one that is now the published tip.
1852
+ const sha = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
1853
+ return { kind: 'merged', sha, from: fromBranch, resolvedPaths };
1854
+ }
1481
1855
 
1482
- // `--no-commit --no-ff` so we control the commit (need to override
1483
- // author) and so we never sneak a fast-forward past the merge-commit
1484
- // explanation gate.
1485
- let mergeStdout = '';
1856
+ /**
1857
+ * One conflicted path as the merge on disk has it: the three index stages
1858
+ * (base, ours, theirs) and the working copy with git's markers. A side that
1859
+ * is not there (a delete on that side) is null; one that is not text or too
1860
+ * large to carry is left out and marked, for the caller to read at the
1861
+ * commit instead. Read while the merge is on disk — the stages exist only
1862
+ * then.
1863
+ */
1864
+ private async describeConflict(cwd: string, repoRelativePath: string): Promise<ConflictedFile> {
1865
+ const truncated: NonNullable<ConflictedFile['truncated']> = [];
1866
+ const side = async (name: 'base' | 'ours' | 'theirs', stage: 1 | 2 | 3): Promise<string | null> => {
1867
+ let bytes: Buffer;
1486
1868
  try {
1487
- const r = await this.git(cwd, [
1488
- 'merge', '--no-commit', '--no-ff', `origin/${targetBranch}`,
1489
- ]);
1490
- mergeStdout = r.stdout;
1869
+ bytes = (await this.gitRunner.run(cwd, ['show', `:${stage}:${repoRelativePath}`], { encoding: 'buffer' })).stdout;
1491
1870
  } catch (err) {
1492
- // Conflict or other failure. Disambiguate via the shared porcelain parse.
1493
- const conflictPaths = await this.conflictedPaths(cwd);
1494
- // Always attempt to abort so a partial merge doesn't poison the tree.
1495
- try {
1496
- await this.git(cwd, ['merge', '--abort']);
1497
- } catch {
1498
- // Already aborted (no MERGE_HEAD) — fine.
1499
- }
1500
- if (conflictPaths.length === 0) throw err;
1501
- return { kind: 'conflicts' as const, paths: conflictPaths };
1871
+ // ONLY a stage git says is not there is a side that has no file (that
1872
+ // side deleted it; the base never had it). Anything else — a deadline,
1873
+ // a repository failure — is not "deleted", and reporting it as such
1874
+ // would have a resolver drop a file over a hiccup.
1875
+ const msg = (err as { stderr?: string }).stderr ?? (err instanceof Error ? err.message : String(err));
1876
+ if (/is in the index, but not at stage|does not exist in|exists on disk, but not in|invalid object name/i.test(msg)) return null;
1877
+ throw err;
1502
1878
  }
1503
-
1504
- // No conflicts. Detect "already up to date" — git prints that exact
1505
- // phrase and stages nothing. Otherwise the merge is staged and waiting
1506
- // for the commit we now author as `user`.
1507
- if (/Already up to date/i.test(mergeStdout)) {
1508
- return { kind: 'clean' as const, alreadyUpToDate: true };
1879
+ return this.carriedText(bytes, () => truncated.push(name));
1880
+ };
1881
+ const base = await side('base', 1);
1882
+ const ours = await side('ours', 2);
1883
+ const theirs = await side('theirs', 3);
1884
+ let merged: string | null = null;
1885
+ // The working copy is read from disk, so it is read only where that
1886
+ // stays inside the checkout: a link (the path itself, or a folder on
1887
+ // the way to it) is never followed — the side is left out instead.
1888
+ const file = path.join(cwd, repoRelativePath);
1889
+ const leaf = await lstatOrNull(file);
1890
+ if (leaf?.isSymbolicLink() || (await linkedAncestor(cwd, path.dirname(file))) !== null) {
1891
+ truncated.push('merged');
1892
+ } else {
1893
+ try {
1894
+ merged = this.carriedText(await fs.readFile(file), () => truncated.push('merged'));
1895
+ } catch (err) {
1896
+ if (!isAbsence(err)) throw err;
1509
1897
  }
1510
- const subject = `Refresh ${sourceBranch} from ${targetBranch}`;
1511
- const body =
1512
- `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
1513
- `as part of opening or refreshing a change request. ` +
1514
- `Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
1515
- `none were detected, so the merge applied cleanly.`;
1516
- await this.git(cwd, [
1517
- 'commit',
1518
- `--author=${user.name} <${user.email}>`,
1519
- '-m', subject,
1520
- '-m', body,
1521
- ]);
1522
- this.accessControl?.invalidate(workspaceId);
1523
- return { kind: 'clean' as const, alreadyUpToDate: false };
1898
+ }
1899
+ return { path: repoRelativePath, base, ours, theirs, merged, ...(truncated.length > 0 ? { truncated } : {}) };
1900
+ }
1901
+
1902
+ /**
1903
+ * `bytes` as text when they are text by the one rule every reader applies
1904
+ * (`utf8Text`: no NUL, valid UTF-8, BOM kept) and small enough to carry;
1905
+ * else null, and `left` is told.
1906
+ */
1907
+ private carriedText(bytes: Buffer, left: () => void): string | null {
1908
+ const text = bytes.length > GitService.CONFLICT_SIDE_MAX_CHARS ? null : utf8Text(bytes);
1909
+ if (text === null) left();
1910
+ return text;
1911
+ }
1912
+
1913
+ /**
1914
+ * Settle one conflicted path on disk and in the index, as the resolution
1915
+ * says. `r.path` is one the merge itself listed as conflicted (matched
1916
+ * exactly by the caller), so it is a path git owns inside the checkout.
1917
+ * A side kept whole is restored by git from its index stage, byte for
1918
+ * byte — never through a text decoding — so a binary file survives it.
1919
+ */
1920
+ private async applyResolution(cwd: string, r: MergeResolution): Promise<void> {
1921
+ const file = path.join(cwd, r.path);
1922
+ // Written and removed where that stays inside the checkout: a folder on
1923
+ // the way that is a link is refused, and a path that is itself a link is
1924
+ // replaced as a path (the link removed first), never written through.
1925
+ if ((await linkedAncestor(cwd, path.dirname(file))) !== null) {
1926
+ throw new WorkflowValidationError(`"${r.path}" is reached through a link, so it is not resolved here`);
1927
+ }
1928
+ if (r.content !== undefined) {
1929
+ if ((await lstatOrNull(file))?.isSymbolicLink()) await fs.unlink(file);
1930
+ await fs.mkdir(path.dirname(file), { recursive: true });
1931
+ await fs.writeFile(file, r.content, 'utf-8');
1932
+ await this.git(cwd, ['add', '--', r.path]);
1933
+ return;
1934
+ }
1935
+ if (r.choose === 'delete') {
1936
+ // `rm` removes a link itself, never what it points at.
1937
+ await fs.rm(file, { force: true });
1938
+ await this.git(cwd, ['add', '-A', '--', r.path]);
1939
+ return;
1940
+ }
1941
+ // `ours`/`theirs`: git checks that side's stage out as it is. A side with
1942
+ // no stage (deleted there) is "does not have our/their version": the
1943
+ // file goes.
1944
+ try {
1945
+ await this.git(cwd, ['checkout', r.choose === 'ours' ? '--ours' : '--theirs', '--', r.path]);
1946
+ } catch (err) {
1947
+ const msg = err instanceof Error ? err.message : String(err);
1948
+ if (!/does not have (our|their) version/i.test(msg)) throw err;
1949
+ await fs.rm(file, { force: true });
1950
+ }
1951
+ await this.git(cwd, ['add', '-A', '--', r.path]);
1952
+ }
1953
+
1954
+ async readFileAtBranch(
1955
+ workspaceId: string,
1956
+ relativePath: string,
1957
+ branch: string,
1958
+ authorize?: (commits: string[]) => Promise<void>,
1959
+ ): Promise<{ sha: string; ref: string; content: string | null }> {
1960
+ assertValidRelativePath(relativePath);
1961
+ assertValidBranchName(branch);
1962
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1963
+ return this.mutex.run(workspaceId, async () => {
1964
+ const cwd = await this.repoDir(workspaceId);
1965
+ // The PUBLISHED tip: what the branch's own workspace holds and has not
1966
+ // shared is nobody else's to read, so `origin/<branch>` comes first and
1967
+ // the local head serves only a branch origin does not have.
1968
+ const { ref, sha } = await this.resolvePublishedBranch(cwd, branch);
1969
+ // The caller's verdict, at every commit the branch resolves to here
1970
+ // (the served one first), BEFORE anything is said about the path at
1971
+ // it: a refusal that names what the path is (a folder) is an answer
1972
+ // about content the caller may not read.
1973
+ if (authorize) await authorize(await this.branchCommits(cwd, branch, sha));
1974
+ // Per file, as every history read: a folder's content at a commit is
1975
+ // every child's, which the per-file gate never checked.
1976
+ await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
1977
+ return { sha, ref, content: await this.readFileAtRef(workspaceId, sha, repoRelativePath) };
1524
1978
  });
1525
1979
  }
1526
1980
 
@@ -1985,15 +2439,23 @@ export class GitService implements IGitService {
1985
2439
  return stdout.trim().length > 0;
1986
2440
  }
1987
2441
 
1988
- /** `git rev-parse --verify` that answers null for a rev that does not resolve (an unborn HEAD). */
2442
+ /**
2443
+ * `git rev-parse --verify --quiet`: the commit `rev` names, or null when
2444
+ * the rev is ABSENT (an unborn HEAD, a ref nobody made) — and nothing
2445
+ * else. Git says "absent" with exit 1 and an empty stderr; a lookup that
2446
+ * fails any other way (a corrupt repository, a deadline) throws, because
2447
+ * every caller reads null as a fact about the repository — "unborn",
2448
+ * "no such tip", "nothing to authorize at" — and a failure dressed as that
2449
+ * fact would make a sync reset an unborn clone or a verdict list lose a
2450
+ * commit.
2451
+ */
1989
2452
  private async revParseOrNull(cwd: string, rev: string): Promise<string | null> {
1990
2453
  try {
1991
2454
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', rev]);
1992
2455
  return stdout.trim() || null;
1993
2456
  } catch (err) {
1994
- // Null is "no such rev"; a deadline is not that answer (see GitRunError).
1995
- if (isGitTimeout(err)) throw err;
1996
- return null;
2457
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && (err.stderr ?? '').trim() === '') return null;
2458
+ throw err;
1997
2459
  }
1998
2460
  }
1999
2461
 
@@ -2206,23 +2668,29 @@ export class GitService implements IGitService {
2206
2668
  workspaceId: string,
2207
2669
  opts: { preserveMerges?: boolean } = {},
2208
2670
  ): Promise<{ treeChanged: boolean }> {
2209
- return this.mutex.run(workspaceId, async () => {
2210
- const cwd = await this.repoDir(workspaceId);
2211
- const branch = await this.checkedOutBranch(cwd);
2212
- // For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
2213
- // a pull that lands only content-identical commits (an empty commit)
2214
- // must not read as a content change. Tolerant of an unborn HEAD (a
2215
- // clone of an empty upstream): "" before + a tree after correctly
2216
- // reads as changed, "" both sides as not.
2217
- const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2218
- // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
2219
- // `refreshRemoteBranchRef` for why both halves are load-bearing.
2220
- const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
2221
- await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
2222
- this.accessControl?.invalidate(workspaceId);
2223
- const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2224
- return { treeChanged: treeAfter !== treeBefore };
2225
- });
2671
+ return this.mutex.run(workspaceId, async () => this.pullUnlocked(workspaceId, await this.repoDir(workspaceId), opts));
2672
+ }
2673
+
2674
+ /** `pull`'s body, for a caller that already holds the workspace's reservation. */
2675
+ private async pullUnlocked(
2676
+ workspaceId: string,
2677
+ cwd: string,
2678
+ opts: { preserveMerges?: boolean } = {},
2679
+ ): Promise<{ treeChanged: boolean }> {
2680
+ const branch = await this.checkedOutBranch(cwd);
2681
+ // For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
2682
+ // a pull that lands only content-identical commits (an empty commit)
2683
+ // must not read as a content change. Tolerant of an unborn HEAD (a
2684
+ // clone of an empty upstream): "" before + a tree after correctly
2685
+ // reads as changed, "" both sides as not.
2686
+ const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2687
+ // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
2688
+ // `refreshRemoteBranchRef` for why both halves are load-bearing.
2689
+ const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
2690
+ await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
2691
+ this.accessControl?.invalidate(workspaceId);
2692
+ const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2693
+ return { treeChanged: treeAfter !== treeBefore };
2226
2694
  }
2227
2695
 
2228
2696
  /**
@@ -2708,7 +3176,8 @@ export class GitService implements IGitService {
2708
3176
  relativePath: string,
2709
3177
  fromBranch: string,
2710
3178
  toBranch: string,
2711
- ): Promise<string> {
3179
+ authorize?: (sides: { from: string[]; to: string[] }) => Promise<void>,
3180
+ ): Promise<{ diff: string; from: string; to: string }> {
2712
3181
  assertValidRelativePath(relativePath);
2713
3182
  assertValidBranchName(fromBranch);
2714
3183
  assertValidBranchName(toBranch);
@@ -2718,8 +3187,21 @@ export class GitService implements IGitService {
2718
3187
  const repoRelativePath = this.stripRepoPrefix(relativePath);
2719
3188
  return this.mutex.run(workspaceId, async () => {
2720
3189
  const cwd = await this.repoDir(workspaceId);
2721
- const fromRef = await this.resolveBranchRef(cwd, fromBranch);
2722
- const toRef = await this.resolveBranchRef(cwd, toBranch);
3190
+ // Each side's COMMIT, read once here and diffed from here on: what the
3191
+ // caller authorizes the answer at. The ref is a name a fetch may move.
3192
+ const fromRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, fromBranch)])).stdout.trim();
3193
+ const toRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, toBranch)])).stdout.trim();
3194
+ const pinned = (diff: string): { diff: string; from: string; to: string } => ({ diff, from: fromRef, to: toRef });
3195
+ // The caller's verdict at every commit each side resolves to here (the
3196
+ // served one first) BEFORE anything is said about the path at either:
3197
+ // a refusal that names what the path is there (a folder) is an answer
3198
+ // about content the caller may not read.
3199
+ if (authorize) {
3200
+ await authorize({
3201
+ from: await this.branchCommits(cwd, fromBranch, fromRef),
3202
+ to: await this.branchCommits(cwd, toBranch, toRef),
3203
+ });
3204
+ }
2723
3205
  await this.assertNotTreeAtRef(cwd, fromRef, repoRelativePath, relativePath);
2724
3206
  await this.assertNotTreeAtRef(cwd, toRef, repoRelativePath, relativePath);
2725
3207
  // When one side is the currently-checked-out branch, diff against the
@@ -2739,11 +3221,7 @@ export class GitService implements IGitService {
2739
3221
  isToCurrent !== isFromCurrent &&
2740
3222
  (await this.isPathUntracked(cwd, repoRelativePath))
2741
3223
  ) {
2742
- return synthesizeUntrackedSideDiff(
2743
- cwd,
2744
- repoRelativePath,
2745
- isToCurrent ? 'added' : 'removed',
2746
- );
3224
+ return pinned(await synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed'));
2747
3225
  }
2748
3226
  const args = ['diff', '--no-color'];
2749
3227
  if (isToCurrent && !isFromCurrent) {
@@ -2765,7 +3243,7 @@ export class GitService implements IGitService {
2765
3243
  args.push('--', repoRelativePath);
2766
3244
  try {
2767
3245
  const { stdout } = await this.git(cwd, args);
2768
- return stdout;
3246
+ return pinned(stdout);
2769
3247
  } catch (err) {
2770
3248
  const msg = err instanceof Error ? err.message : String(err);
2771
3249
  throw new Error(
@@ -3166,9 +3644,8 @@ export class GitService implements IGitService {
3166
3644
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3167
3645
  }
3168
3646
  return this.mutex.run(workspaceId, async () => {
3169
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3170
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3171
- return this.prChangedPathsAt(cwd, baseRef, headRef, opts);
3647
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3648
+ return flattenChangedPaths(await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head), opts);
3172
3649
  });
3173
3650
  }
3174
3651
 
@@ -3243,13 +3720,51 @@ export class GitService implements IGitService {
3243
3720
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3244
3721
  }
3245
3722
  return this.mutex.run(workspaceId, async () => {
3246
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3247
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3248
- const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
3723
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3724
+ const entries = await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head);
3249
3725
  return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
3250
3726
  });
3251
3727
  }
3252
3728
 
3729
+ /**
3730
+ * {@link prChangedEntriesAt} for a pair of COMMITS, read once per pair
3731
+ * (see `changedEntriesByCommits`). The caller holds the reservation, as for
3732
+ * `prChangedEntriesAt`; a memo hit holds it for nothing, which is cheaper
3733
+ * than a second code path around the lock.
3734
+ */
3735
+ private async prChangedEntriesBetweenCommits(
3736
+ cwd: string,
3737
+ baseSha: string,
3738
+ headSha: string,
3739
+ ): Promise<NameStatusEntry[]> {
3740
+ const key = `${baseSha}\0${headSha}`;
3741
+ const known = this.changedEntriesByCommits.get(key);
3742
+ if (known) return known;
3743
+ let pending = this.changedEntriesInFlight.get(key);
3744
+ if (!pending) {
3745
+ pending = this.prChangedEntriesAt(cwd, baseSha, headSha)
3746
+ .then((entries) => {
3747
+ if (this.changedEntriesByCommits.size >= GitService.CHANGED_ENTRIES_MAX) {
3748
+ const oldest = this.changedEntriesByCommits.keys().next().value;
3749
+ if (oldest !== undefined) this.changedEntriesByCommits.delete(oldest);
3750
+ }
3751
+ this.changedEntriesByCommits.set(key, entries);
3752
+ return entries;
3753
+ })
3754
+ .finally(() => {
3755
+ if (this.changedEntriesInFlight.get(key) === pending) this.changedEntriesInFlight.delete(key);
3756
+ });
3757
+ // Same bound as the memo: evicting an in-flight entry costs at most one
3758
+ // redundant diff for that pair, never a lost answer.
3759
+ if (this.changedEntriesInFlight.size >= GitService.CHANGED_ENTRIES_MAX) {
3760
+ const oldest = this.changedEntriesInFlight.keys().next().value;
3761
+ if (oldest !== undefined) this.changedEntriesInFlight.delete(oldest);
3762
+ }
3763
+ this.changedEntriesInFlight.set(key, pending);
3764
+ }
3765
+ return pending;
3766
+ }
3767
+
3253
3768
  /**
3254
3769
  * The merge-base commit of a change request's two branches (their origin
3255
3770
  * refs), or null when they share no history. This is the "before" the CR's
@@ -3590,16 +4105,23 @@ export class GitService implements IGitService {
3590
4105
  baseBranch: string,
3591
4106
  headBranch: string,
3592
4107
  ): Promise<{ base: string; head: string } | null> {
3593
- const shas: string[] = [];
3594
- for (const branch of [baseBranch, headBranch]) {
3595
- try {
3596
- shas.push((await this.resolvePublishedBranch(cwd, branch)).sha);
3597
- } catch (err) {
3598
- if (err instanceof WorkflowValidationError) return null;
3599
- throw err;
3600
- }
4108
+ try {
4109
+ return await this.publishedPrEnds(cwd, baseBranch, headBranch);
4110
+ } catch (err) {
4111
+ if (err instanceof WorkflowValidationError) return null;
4112
+ throw err;
3601
4113
  }
3602
- return { base: shas[0], head: shas[1] };
4114
+ }
4115
+
4116
+ /** The two commits a change request is read between, each resolved as {@link resolvePublishedBranch} does. */
4117
+ private async publishedPrEnds(
4118
+ cwd: string,
4119
+ baseBranch: string,
4120
+ headBranch: string,
4121
+ ): Promise<{ base: string; head: string }> {
4122
+ const base = (await this.resolvePublishedBranch(cwd, baseBranch)).sha;
4123
+ const head = (await this.resolvePublishedBranch(cwd, headBranch)).sha;
4124
+ return { base, head };
3603
4125
  }
3604
4126
 
3605
4127
  /**
@@ -3625,6 +4147,13 @@ export class GitService implements IGitService {
3625
4147
  branch: string,
3626
4148
  ): Promise<{ ref: string; sha: string }> {
3627
4149
  for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
4150
+ // The ref file first (`readRefSha`): no process, same answer, same
4151
+ // freshness. Git is asked only for what the files cannot decide — a
4152
+ // missing ref is decided by the files and the loop moves on; an
4153
+ // unreadable one is git's to answer, as the authority.
4154
+ const fromFiles = await readRefSha(cwd, ref);
4155
+ if (fromFiles === 'missing') continue;
4156
+ if (fromFiles !== 'unreadable') return { ref, sha: fromFiles.sha };
3628
4157
  try {
3629
4158
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
3630
4159
  return { ref, sha: stdout.trim() };
@@ -3639,6 +4168,26 @@ export class GitService implements IGitService {
3639
4168
  throw new WorkflowValidationError(`unknown branch: ${branch}`);
3640
4169
  }
3641
4170
 
4171
+ /**
4172
+ * Every commit `branch` resolves to in this repository — its local head
4173
+ * and its published tip (`origin/<branch>`), whichever resolve, deduped,
4174
+ * `served` first. What a caller's read verdict is asked at: content of
4175
+ * another branch is served from one of them, and a grant at the served one
4176
+ * must not outrun a denial at the other (a draft's unshared commits may
4177
+ * carry rules its published tip does not, and the other way round) — the
4178
+ * rule the compare route always applied to both refs, pinned to commits.
4179
+ */
4180
+ private async branchCommits(cwd: string, branch: string, served: string): Promise<string[]> {
4181
+ const commits = [served];
4182
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
4183
+ // Null is ABSENT and nothing else (`revParseOrNull`): a lookup that
4184
+ // fails otherwise throws, so this list never quietly loses a commit.
4185
+ const sha = await this.revParseOrNull(cwd, ref);
4186
+ if (sha && !commits.includes(sha)) commits.push(sha);
4187
+ }
4188
+ return commits;
4189
+ }
4190
+
3642
4191
  /**
3643
4192
  * Resolve a branch name to a concrete ref the local clone knows about.
3644
4193
  * Tries the local head first, falls back to the matching remote-tracking
@@ -4097,7 +4646,7 @@ export class GitService implements IGitService {
4097
4646
  * from a real failure are unaffected.
4098
4647
  */
4099
4648
  private git(cwd: string, args: string[], opts?: Omit<GitRunOptions, 'encoding'>): Promise<GitRunResult> {
4100
- return this.gitRunner.run(cwd, args, {
4649
+ const run = () => this.gitRunner.run(cwd, args, {
4101
4650
  ...opts,
4102
4651
  // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
4103
4652
  // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as glob
@@ -4112,6 +4661,18 @@ export class GitService implements IGitService {
4112
4661
  // a search for a file literally named that.
4113
4662
  env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
4114
4663
  });
4664
+ // A write to the git config is a write to the ONE file every worktree of
4665
+ // the repository shares, which git refuses a second writer of rather than
4666
+ // waiting: it runs as the repository's administrator. Everything else —
4667
+ // a fetch, a commit, a branch delete — git serializes with its own locks,
4668
+ // as it does for any two worktrees of one repository.
4669
+ return writesSharedConfig(args) ? this.asRepositoryAdministrator(cwd, run) : run();
4670
+ }
4671
+
4672
+ /** `run` as the repository's administrator when `cwd` is a worktree of it; as itself for a full clone. */
4673
+ private async asRepositoryAdministrator<T>(cwd: string, run: () => Promise<T>): Promise<T> {
4674
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
4675
+ return repository ? repository.exclusive(run) : run();
4115
4676
  }
4116
4677
 
4117
4678
  /**
@@ -4299,3 +4860,35 @@ export function parseNumstatZ(out: string): NumstatEntry[] {
4299
4860
  }
4300
4861
  return entries;
4301
4862
  }
4863
+
4864
+ /**
4865
+ * Whether a git command WRITES the config — the one file every worktree of
4866
+ * the knowledge base's repository shares, which git refuses a second writer
4867
+ * of rather than waiting — and so must run as the repository's administrator
4868
+ * (`IKbRepository.exclusive`): a `config` that sets or unsets, a `remote`
4869
+ * change, a branch rename, delete or upstream change (its config section is
4870
+ * written, or removed with the branch), a push that sets the upstream. Reads
4871
+ * of the config are not writes. Nothing else is: the refs, `packed-refs` and
4872
+ * the object store are locked by git itself, and the worktree list is the
4873
+ * repository's own to serialize (`KbRepository`).
4874
+ *
4875
+ * Leading `-c key=value` / `-C dir` options are skipped to find the subcommand.
4876
+ */
4877
+ function writesSharedConfig(args: readonly string[]): boolean {
4878
+ let i = 0;
4879
+ while (i < args.length && (args[i] === '-c' || args[i] === '-C')) i += 2;
4880
+ const sub = args[i];
4881
+ const rest = args.slice(i + 1);
4882
+ switch (sub) {
4883
+ case 'remote':
4884
+ return true;
4885
+ case 'config':
4886
+ return !rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l|--show-origin)$/.test(a));
4887
+ case 'branch':
4888
+ return rest.some((a) => /^(-d|-D|--delete|-m|-M|--move|-u|--set-upstream-to=.*|--unset-upstream)$/.test(a));
4889
+ case 'push':
4890
+ return rest.some((a) => a === '-u' || a === '--set-upstream');
4891
+ default:
4892
+ return false;
4893
+ }
4894
+ }