@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
@@ -4,13 +4,17 @@ import { logger } from '../../../shared/logging.js';
4
4
  import { isFolderPlaceholder } from '@bevel-software/platform-shared';
5
5
  import { mergeCommitMessageNames } from './merge-commit.js';
6
6
  import { AccessDeniedError } from '../../access-model/access-errors.js';
7
- import { WorkspaceMutex } from '../../kb-fs/mutex.js';
7
+ import { WorkspaceIdMutex } from '../../kb-fs/index.js';
8
8
  import { isAbsence } from '../../../shared/fs.contract.js';
9
9
  import { assertInsideRepo } from '../../kb-fs/repo-path.js';
10
10
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
11
+ import { readRefSha } from '../../kb-fs/index.js';
12
+ import { utf8Text } from '../../../shared/utf8-text.js';
13
+ import { lstatOrNull } from '../../../shared/rename-no-replace.js';
14
+ import { linkedAncestor } from '../../../shared/no-link-below.js';
11
15
  import { assertValidBranchName, assertValidRelativePath, isBranchAuthoredBy, isOwnSuggestionsBranch, } from '../../kb-fs/branch-name.js';
12
16
  import { AppliedChangeMismatchError, BranchAuthorshipError, BranchDeleteRefusedError, WorkflowDomainError, WorkflowValidationError, ProtectedBranchError, PullRebaseConflictError, RemoteBranchGoneError, VersionNotOnBranchError, isMissingRemoteBranchFailure, } from '../../../shared/domain-errors.js';
13
- import { GitRunError, assertOneSpecPerLine, isGitTimeout, redactGitToken, } from '../../../shared/git.contract.js';
17
+ import { GitRunError, assertOneSpecPerLine, redactGitToken, } from '../../../shared/git.contract.js';
14
18
  import { NodeGitRunner } from './node-git-runner.js';
15
19
  import { printable } from '../../../shared/printable.js';
16
20
  import { sanitizeError } from '../sanitize-error.js';
@@ -243,6 +247,24 @@ export class GitService {
243
247
  // concurrent processes worst case — fast for typical (<8) feature-branch
244
248
  // counts, calm under large branch lists.
245
249
  static MAX_AHEAD_BEHIND_CONCURRENCY = 8;
250
+ /**
251
+ * A change request's changed entries, keyed by the two commits its diff
252
+ * was read between. Two commits never change, and neither does the
253
+ * merge-base between them, so the three-dot diff of a pair is a fact: a
254
+ * push moves a tip, the pair changes, the old entry is simply never asked
255
+ * for again. No TTL, no invalidation.
256
+ *
257
+ * This is what a change-request listing was paying git for on every read:
258
+ * one `diff` per open request, polled every minute and refetched on every
259
+ * event, where nothing had moved. Now git is asked once per pair, which is
260
+ * once per push. Keyed by commits alone, not by workspace: every clone of
261
+ * the knowledge base holds the same objects, so the answer is the same in
262
+ * all of them. Bounded FIFO; the bound only caps memory.
263
+ */
264
+ changedEntriesByCommits = new Map();
265
+ /** Diffs being read right now, so concurrent misses on one pair share one `git diff`. */
266
+ changedEntriesInFlight = new Map();
267
+ static CHANGED_ENTRIES_MAX = 4096;
246
268
  constructor(workspaceService,
247
269
  /**
248
270
  * Workflow lifecycle hooks. This service only consults the ADVISORY
@@ -251,7 +273,11 @@ export class GitService {
251
273
  * replaced). No hooks registered = no commit-time validation, which is
252
274
  * safe because validation is advisory and never blocks a commit.
253
275
  */
254
- hooks, kb, mutex = new WorkspaceMutex(), accessControl = null,
276
+ hooks, kb,
277
+ // Keyed by the CANONICAL workspace id: a route's Express-decoded `:id`
278
+ // (`alice/feature`) and a tool's `alice%2Ffeature` name one clone, and
279
+ // must take one reservation.
280
+ mutex = new WorkspaceIdMutex(), accessControl = null,
255
281
  /**
256
282
  * How git is actually run — see `shared/git.contract.ts`. The composition
257
283
  * root passes the one runner, carrying the deployment's configured
@@ -321,7 +347,9 @@ export class GitService {
321
347
  * above rather than kept separately, so it cannot disagree with it.
322
348
  */
323
349
  lastRemoteContact() {
324
- let latest = null;
350
+ // The repository's fetches are every worktree's; the per-workspace record
351
+ // is a full clone's.
352
+ let latest = this.workspaceService.repository?.lastContact() ?? null;
325
353
  for (const [workspaceId, at] of this.lastImplicitFetchAt) {
326
354
  if (latest === null || at > latest.at) {
327
355
  latest = { at, ok: this.lastImplicitFetchOk.get(workspaceId) ?? false };
@@ -419,12 +447,22 @@ export class GitService {
419
447
  // Union local heads with origin's remote-tracking refs: a fresh per-user
420
448
  // clone only has one local head (HEAD's default), so without this we'd hide
421
449
  // target-company-state and every draft pushed by another user.
450
+ //
451
+ // The workspace is a worktree of the one repository, which holds every
452
+ // OTHER worktree's local branch too. Those are not this workspace's: a
453
+ // clone never had them. A local head checked out in another worktree
454
+ // is read as that branch's remote-tracking ref alone (and not at all
455
+ // once origin no longer has it), so a branch deleted on origin is not
456
+ // kept alive by its own worktree's local ref — the orphan sweep reads
457
+ // this list. A branch is checked out in at most one worktree, so "not
458
+ // this one's HEAD" is "another's".
422
459
  const { stdout } = await this.git(cwd, [
423
460
  'for-each-ref',
424
- '--format=%(refname:short)%09%(refname)',
461
+ '--format=%(refname:short)%09%(refname)%09%(worktreepath)',
425
462
  'refs/heads',
426
463
  'refs/remotes/origin',
427
464
  ]);
465
+ const ownBranch = (await this.git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then((r) => r.stdout, () => '')).trim();
428
466
  const buckets = new Map();
429
467
  for (const line of stdout.split('\n')) {
430
468
  const trimmed = line.trim();
@@ -434,7 +472,7 @@ export class GitService {
434
472
  if (tab < 0)
435
473
  continue;
436
474
  const rawShort = trimmed.slice(0, tab);
437
- const ref = trimmed.slice(tab + 1);
475
+ const [ref = '', worktreePath = ''] = trimmed.slice(tab + 1).split('\t');
438
476
  if (!rawShort || !ref)
439
477
  continue;
440
478
  // Skip symbolic refs up-front. Different git versions report
@@ -447,6 +485,8 @@ export class GitService {
447
485
  const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
448
486
  if (!shortName)
449
487
  continue;
488
+ if (isLocal && worktreePath !== '' && shortName !== ownBranch)
489
+ continue;
450
490
  // Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
451
491
  // if a future git version lands us here, don't bucket it as a branch.
452
492
  if (shortName === 'HEAD')
@@ -530,6 +570,20 @@ export class GitService {
530
570
  * and the object store, neither of which checkout/status/commit touch.
531
571
  */
532
572
  async fetchOriginIfStale(cwd, workspaceId, force = false) {
573
+ // A worktree's remote-tracking refs are the repository's: ONE fetch for
574
+ // every workspace, throttled, joined and reserved there. The per-workspace
575
+ // record below is for a full clone from an earlier release, which fetches
576
+ // on its own until the boot phase converts it.
577
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
578
+ if (repository) {
579
+ return repository.fetch({
580
+ maxAgeMs: GitService.IMPLICIT_FETCH_TTL_MS,
581
+ // A host that is down is not asked again by every poll inside the
582
+ // window, as the per-workspace record below stamps a failure too.
583
+ failureBackoffMs: GitService.IMPLICIT_FETCH_TTL_MS,
584
+ force,
585
+ });
586
+ }
533
587
  const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
534
588
  if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
535
589
  return this.lastImplicitFetchOk.get(workspaceId) ?? true;
@@ -603,8 +657,9 @@ export class GitService {
603
657
  // navigation to the new draft's URL fails with
604
658
  // "Remote branch <name> not found in upstream origin". Roll the local
605
659
  // ref back on failure so a retry isn't blocked by "branch already exists".
660
+ // No `-u`: the local head is dropped right below, and its upstream with it.
606
661
  try {
607
- await this.git(cwd, ['push', '-u', 'origin', name]);
662
+ await this.git(cwd, ['push', 'origin', name]);
608
663
  }
609
664
  catch (err) {
610
665
  await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
@@ -725,7 +780,15 @@ export class GitService {
725
780
  let localTip = null;
726
781
  if (await this.refExists(cwd, `refs/heads/${name}`)) {
727
782
  localTip = await this.revParseOrNull(cwd, `refs/heads/${name}`);
728
- await this.git(cwd, ['branch', '-D', name]);
783
+ await this.git(cwd, ['branch', '-D', name]).catch((err) => {
784
+ // In a repository of worktrees the branch's local ref is shared, and
785
+ // git keeps it while the branch's own workspace has it checked out.
786
+ // Removing that workspace — which the caller does next — takes the
787
+ // worktree and the ref with it.
788
+ if (/checked out at|used by worktree/i.test(err instanceof Error ? err.message : String(err)))
789
+ return;
790
+ throw err;
791
+ });
729
792
  }
730
793
  return { lastCommit: remoteTip ?? localTip };
731
794
  });
@@ -793,18 +856,19 @@ export class GitService {
793
856
  const sha = /^[0-9a-f]{4,64}$/i;
794
857
  if (!sha.test(ancestor) || !sha.test(descendant))
795
858
  throw new Error('isAncestor takes commit ids');
796
- return this.mutex.run(workspaceId, async () => {
797
- const cwd = await this.repoDir(workspaceId);
798
- try {
799
- await this.git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]);
800
- return true;
801
- }
802
- catch (err) {
803
- if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1)
804
- return false;
805
- throw err;
806
- }
807
- });
859
+ return this.mutex.run(workspaceId, async () => this.isAncestorUnlocked(await this.repoDir(workspaceId), ancestor, descendant));
860
+ }
861
+ /** `isAncestor`'s body, for a caller that already holds the workspace's reservation. */
862
+ async isAncestorUnlocked(cwd, ancestor, descendant) {
863
+ try {
864
+ await this.git(cwd, ['merge-base', '--is-ancestor', ancestor, descendant]);
865
+ return true;
866
+ }
867
+ catch (err) {
868
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1)
869
+ return false;
870
+ throw err;
871
+ }
808
872
  }
809
873
  /**
810
874
  * True iff the given ref (e.g. `refs/heads/foo`, `refs/remotes/origin/foo`)
@@ -1221,7 +1285,26 @@ export class GitService {
1221
1285
  const touched = await this.unpushedTouchedPaths(cwd);
1222
1286
  await this.assertCanWriteAtRef(workspaceId, `origin/${branch}`, user.email, touched);
1223
1287
  }
1224
- await this.git(cwd, ['push', '-u', 'origin', branch]);
1288
+ // No `-u`: it writes the upstream into the config during the push, and
1289
+ // a worktree's config is the repository's, shared by every workspace —
1290
+ // git refuses a second writer of it rather than waiting. The upstream is
1291
+ // stamped after the push instead, as one short write made as the
1292
+ // repository's administrator (see `git`).
1293
+ await this.git(cwd, ['push', 'origin', branch]);
1294
+ // The stamp is maintenance after a push that LANDED: a config the
1295
+ // repository's administrator could not write just now (a lock held by
1296
+ // another worktree's stamp) does not make the branch unpublished, and
1297
+ // reporting it as a refused push would hand a published commit to
1298
+ // recovery. Logged; the next push stamps it.
1299
+ try {
1300
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
1301
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
1302
+ }
1303
+ catch (err) {
1304
+ log.warn(`"${branch}" is pushed; its tracking config could not be stamped after the push:`, {
1305
+ detail: redactGitToken(err instanceof Error ? err.message : String(err), this.credentials.token()),
1306
+ });
1307
+ }
1225
1308
  }
1226
1309
  }
1227
1310
  // `forcePush` method removed. The workflow layer no longer auto-force-pushes
@@ -1256,73 +1339,375 @@ export class GitService {
1256
1339
  assertValidBranchName(sourceBranch);
1257
1340
  assertValidBranchName(targetBranch);
1258
1341
  assertValidAuthor(user);
1342
+ // The one merge protocol (`mergeRefIntoCheckout`), asked for what the
1343
+ // auto-merge needs: no resolutions, the conflicted paths alone, no
1344
+ // publish. Under the per-branch workspace model the workspace IS the
1345
+ // source branch, so the checked-out guard is not asked for; nor is the
1346
+ // dirty-tree one — save=share keeps the tree clean before an auto-merge,
1347
+ // and a dirty one is an upstream bug `statusInternal` already logs.
1259
1348
  return this.mutex.run(workspaceId, async () => {
1260
- const cwd = await this.repoDir(workspaceId);
1261
- // Under the per-branch workspace model, the workspace's identity is
1262
- // the source branch — the legacy "current branch must equal sourceBranch"
1263
- // guard was an artifact of the single-workspace-many-branches model and
1264
- // is no longer needed. The legacy dirty-tree refusal is also gone:
1265
- // save=share guarantees the tree is clean before any auto-merge runs,
1266
- // and the only way it could be dirty now is an upstream bug, which
1267
- // `statusInternal` already logs server-side.
1268
- // Refresh the target ref so we merge against the latest published
1269
- // state. Failing the fetch here is non-recoverable for this
1270
- // operation: a stale `origin/<target>` would let us silently miss
1271
- // conflicts the next push would surface, and the whole point of
1272
- // `mergeFromOrigin` is to surface those conflicts now. Propagate
1273
- // the error so the auto-merge aborts cleanly instead of producing
1274
- // a merge based on a stale ref. Explicit destination refspec for the
1275
- // same reason as `mergeChangeRequest`: a bare `fetch origin <branch>`
1276
- // updates `origin/<branch>` only as far as `remote.origin.fetch` allows,
1277
- // and a drifted refspec would hand this merge the stale ref it is
1278
- // explicitly trying to avoid.
1279
- await this.git(cwd, [
1280
- 'fetch', '--no-write-fetch-head', 'origin',
1281
- `+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
1282
- ]);
1283
- // `--no-commit --no-ff` so we control the commit (need to override
1284
- // author) and so we never sneak a fast-forward past the merge-commit
1285
- // explanation gate.
1286
- let mergeStdout = '';
1287
- try {
1288
- const r = await this.git(cwd, [
1289
- 'merge', '--no-commit', '--no-ff', `origin/${targetBranch}`,
1290
- ]);
1291
- mergeStdout = r.stdout;
1349
+ const outcome = await this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), sourceBranch, targetBranch, user, {
1350
+ subject: `Refresh ${sourceBranch} from ${targetBranch}`,
1351
+ body: `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
1352
+ `as part of opening or refreshing a change request. ` +
1353
+ `Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
1354
+ `none were detected, so the merge applied cleanly.`,
1355
+ resolutions: new Map(),
1356
+ guardCheckout: false,
1357
+ describeConflicts: false,
1358
+ });
1359
+ if (outcome.kind === 'conflicts')
1360
+ return { kind: 'conflicts', paths: outcome.unresolved };
1361
+ return { kind: 'clean', alreadyUpToDate: outcome.kind === 'nothing-to-merge' };
1362
+ });
1363
+ }
1364
+ /** The most of one side of a conflicted file that travels in an answer; past it the side is read by the caller instead. */
1365
+ static CONFLICT_SIDE_MAX_CHARS = 200_000;
1366
+ async mergeIntoBranch(workspaceId, branch, fromBranch, user, opts) {
1367
+ assertValidBranchName(branch);
1368
+ assertValidBranchName(fromBranch);
1369
+ assertValidAuthor(user);
1370
+ if (/\n\s*\n/.test(opts.subject)) {
1371
+ throw new WorkflowValidationError('merge commit subject must be a single paragraph');
1372
+ }
1373
+ // Every resolution names a repository path; which of them are conflicted
1374
+ // is only known inside the merge, where they are matched exactly.
1375
+ const resolutions = new Map();
1376
+ // A resolution writes a file on the branch: it is somebody's to
1377
+ // authorize, and a caller that brings none writes nothing (closed).
1378
+ if ((opts.resolutions?.length ?? 0) > 0 && typeof opts.authorizeResolutions !== 'function') {
1379
+ throw new Error('mergeIntoBranch: resolutions need an `authorizeResolutions` to be asked inside the merge');
1380
+ }
1381
+ for (const r of opts.resolutions ?? []) {
1382
+ assertValidRelativePath(r.path);
1383
+ const p = this.stripRepoPrefix(r.path);
1384
+ const ways = [r.content !== undefined, r.choose !== undefined].filter(Boolean).length;
1385
+ if (ways !== 1) {
1386
+ throw new WorkflowValidationError(`resolution for "${p}": give either \`content\` or \`choose\`, not ${ways === 0 ? 'neither' : 'both'}`);
1292
1387
  }
1293
- catch (err) {
1294
- // Conflict or other failure. Disambiguate via the shared porcelain parse.
1295
- const conflictPaths = await this.conflictedPaths(cwd);
1296
- // Always attempt to abort so a partial merge doesn't poison the tree.
1297
- try {
1298
- await this.git(cwd, ['merge', '--abort']);
1388
+ if (r.choose !== undefined && !['ours', 'theirs', 'delete'].includes(r.choose)) {
1389
+ throw new WorkflowValidationError(`resolution for "${p}": \`choose\` is \`ours\`, \`theirs\` or \`delete\``);
1390
+ }
1391
+ if (resolutions.has(p))
1392
+ throw new WorkflowValidationError(`"${p}" is resolved twice`);
1393
+ resolutions.set(p, { ...r, path: p });
1394
+ }
1395
+ return this.mutex.run(workspaceId, async () => this.mergeRefIntoCheckout(workspaceId, await this.repoDir(workspaceId), branch, fromBranch, user, {
1396
+ subject: opts.subject,
1397
+ body: opts.body,
1398
+ resolutions,
1399
+ guardCheckout: true,
1400
+ describeConflicts: true,
1401
+ publish: opts.publish,
1402
+ authorizeResolutions: opts.authorizeResolutions,
1403
+ }));
1404
+ }
1405
+ /**
1406
+ * THE merge of `origin/<fromBranch>` into the checkout at `cwd` — what
1407
+ * `mergeFromOrigin` (the change-request auto-merge) and `mergeIntoBranch`
1408
+ * (the agent's update) both run. The caller holds the workspace's
1409
+ * reservation. `--no-commit --no-ff`, so the commit is authored as `user`
1410
+ * and the record of what was merged is never fast-forwarded past.
1411
+ *
1412
+ * The checkout is never left mid-merge, whatever happens: every exit after
1413
+ * `git merge` has started — a conflict described, a resolution refused or
1414
+ * failed, a commit or a publish that failed — runs `merge --abort` first,
1415
+ * and an abort that itself fails is reported, never swallowed, because a
1416
+ * checkout stuck in a merge is what breaks the commit worker and every
1417
+ * later operation on the branch.
1418
+ *
1419
+ * `publish` runs over the merge commit inside this same reservation, so no
1420
+ * save or sync can land between the commit and its push; it is the
1421
+ * caller's push policy (the workflow's `pushWithRecovery`: one replay onto
1422
+ * the moved branch with the merge kept, `--rebase-merges`, then the typed
1423
+ * hand-off) over this checkout's own unlocked moves.
1424
+ */
1425
+ async mergeRefIntoCheckout(workspaceId, cwd, branch, fromBranch, user, opts) {
1426
+ const theirsRef = `refs/remotes/origin/${fromBranch}`;
1427
+ if (opts.guardCheckout) {
1428
+ const checkedOut = await this.currentBranch(cwd);
1429
+ if (checkedOut !== branch) {
1430
+ throw new WorkflowValidationError(`the workspace is on "${checkedOut}", not on "${branch}"`);
1431
+ }
1432
+ // A merge rewrites tracked files. Edits still on their way to a commit
1433
+ // (a save the worker has not landed) would be swept into the merge
1434
+ // commit under this user's name, or lost under the merge's version.
1435
+ const { stdout: dirty } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
1436
+ if (parsePorcelainZ(dirty).length > 0) {
1437
+ throw new WorkflowDomainError(`"${branch}" has edits that are not committed yet. Try again once they are saved.`, 409, { kind: 'branch-busy', branch });
1438
+ }
1439
+ }
1440
+ // `git merge`/`git commit` need a committer identity (see `mergeChangeRequest`).
1441
+ await this.git(cwd, ['config', 'user.name', BOT_NAME]);
1442
+ await this.git(cwd, ['config', 'user.email', BOT_EMAIL]);
1443
+ // The published tip, explicitly: a bare `fetch origin <branch>` updates
1444
+ // the tracking ref only as far as `remote.origin.fetch` allows, and a
1445
+ // stale ref would hide exactly the conflicts this merge exists to surface.
1446
+ await this.git(cwd, ['fetch', '--no-write-fetch-head', 'origin', `+refs/heads/${fromBranch}:${theirsRef}`]);
1447
+ const ours = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
1448
+ // The COMMIT the merge is of, from here on — never the ref it was read
1449
+ // from: the repository's refs are shared by every worktree and moved by
1450
+ // every fetch (the timer's sync, another workspace's merge), so what is
1451
+ // authorized, merged, described and recorded is this one sha.
1452
+ const theirs = (await this.git(cwd, ['rev-parse', theirsRef])).stdout.trim();
1453
+ // Resolutions are authorized here, on the clean checkout at `ours` with
1454
+ // `theirs` fetched, before the merge changes a single file. Which of
1455
+ // them are conflicted is matched after the merge; a stray one is refused.
1456
+ if (opts.resolutions.size > 0 && opts.authorizeResolutions)
1457
+ await opts.authorizeResolutions({ ours, theirs });
1458
+ // A merge with nothing to settle leaves no resolution a place to go: one
1459
+ // supplied anyway names a path that is not conflicted, and is refused as
1460
+ // such rather than dropped on the floor behind a published merge.
1461
+ const noConflictsFor = () => new WorkflowValidationError(`nothing conflicted, so nothing is resolved here: ${[...opts.resolutions.keys()].join(', ')}.`, { kind: 'not-conflicted', paths: [...opts.resolutions.keys()] });
1462
+ // Nothing to merge is a question of ANCESTRY, not of content: a source
1463
+ // the branch already reaches is merged; one it does not reach is merged
1464
+ // in even when the two trees are identical, so the branch records that
1465
+ // it holds the source's history and stops reading as behind it.
1466
+ if (await this.isAncestorUnlocked(cwd, theirs, ours)) {
1467
+ if (opts.resolutions.size > 0)
1468
+ throw noConflictsFor();
1469
+ return { kind: 'nothing-to-merge', sha: ours, from: fromBranch };
1470
+ }
1471
+ try {
1472
+ await this.git(cwd, ['merge', '--no-commit', '--no-ff', theirs]);
1473
+ }
1474
+ catch (err) {
1475
+ // A conflict, or another failure: either way the merge may be on disk
1476
+ // from here on, and every exit below undoes it.
1477
+ return this.settleMerge(cwd, async () => {
1478
+ const conflicted = await this.conflictedPaths(cwd);
1479
+ if (conflicted.length === 0) {
1480
+ throw new Error(`git merge failed without detectable conflicts: ${redactGitToken(err instanceof Error ? err.message : String(err), this.credentials.token())}`);
1299
1481
  }
1300
- catch {
1301
- // Already aborted (no MERGE_HEAD) — fine.
1482
+ const stray = [...opts.resolutions.keys()].filter((p) => !conflicted.includes(p));
1483
+ if (stray.length > 0) {
1484
+ // Only a conflicted path is written by a resolution: anything else
1485
+ // is an ordinary write, which goes through the file tools and their
1486
+ // locks. Refused whole, before any resolution is applied — naming
1487
+ // the caller's own paths only, never the conflicted set (which the
1488
+ // caller is told of, cut to what they may read, by the answer).
1489
+ throw new WorkflowValidationError(`not conflicted, so not resolved here: ${stray.join(', ')}.`, { kind: 'not-conflicted', paths: stray });
1302
1490
  }
1303
- if (conflictPaths.length === 0)
1491
+ const unresolved = conflicted.filter((p) => !opts.resolutions.has(p));
1492
+ if (unresolved.length > 0) {
1493
+ // Described while the merge is on disk — the three stages and the
1494
+ // marked-up working copy exist only then — and undone (by
1495
+ // `settleMerge`) before the answer leaves the reservation.
1496
+ const mergeBase = await this.git(cwd, ['merge-base', ours, theirs])
1497
+ .then((r) => r.stdout.trim() || null)
1498
+ .catch(() => null);
1499
+ const files = [];
1500
+ if (opts.describeConflicts)
1501
+ for (const p of conflicted)
1502
+ files.push(await this.describeConflict(cwd, p));
1503
+ return { done: false, outcome: { kind: 'conflicts', from: fromBranch, mergeBase, ours, theirs, files, unresolved } };
1504
+ }
1505
+ for (const p of conflicted)
1506
+ await this.applyResolution(cwd, opts.resolutions.get(p));
1507
+ const still = await this.conflictedPaths(cwd);
1508
+ if (still.length > 0)
1509
+ throw new Error(`still conflicted after the resolutions were applied: ${still.join(', ')}`);
1510
+ return {
1511
+ done: true,
1512
+ outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, conflicted),
1513
+ };
1514
+ });
1515
+ }
1516
+ // A clean merge: committed whatever it staged — an empty diff (the two
1517
+ // sides made the same change) is still the source's history joined.
1518
+ return this.settleMerge(cwd, async () => {
1519
+ if (opts.resolutions.size > 0)
1520
+ throw noConflictsFor();
1521
+ return { done: true, outcome: await this.commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, []) };
1522
+ });
1523
+ }
1524
+ /**
1525
+ * Run `work` while a merge may be on disk, and leave none: an answer with
1526
+ * `done: false` (a conflict described, nothing to merge) is aborted before
1527
+ * it is returned, and a failure anywhere in `work` is aborted before it is
1528
+ * rethrown. A commit that landed (`done: true`) needs no abort. An abort
1529
+ * that fails while a merge is still in progress is the one thing never
1530
+ * swallowed: it is reported with the failure it followed.
1531
+ */
1532
+ async settleMerge(cwd, work) {
1533
+ let result;
1534
+ try {
1535
+ result = await work();
1536
+ }
1537
+ catch (err) {
1538
+ await this.abortMerge(cwd, err);
1539
+ throw err;
1540
+ }
1541
+ if (!result.done)
1542
+ await this.abortMerge(cwd);
1543
+ return result.outcome;
1544
+ }
1545
+ /**
1546
+ * `git merge --abort`, and proof that nothing is left: a failed abort with
1547
+ * no `MERGE_HEAD` is the benign "no merge to abort"; one that leaves
1548
+ * `MERGE_HEAD` behind is thrown (wrapping `after`, the failure that led
1549
+ * here, when there is one), because the checkout is now stuck.
1550
+ */
1551
+ async abortMerge(cwd, after) {
1552
+ try {
1553
+ await this.git(cwd, ['merge', '--abort']);
1554
+ return;
1555
+ }
1556
+ catch (abortErr) {
1557
+ const merging = await this.refExists(cwd, 'MERGE_HEAD').catch(() => true);
1558
+ if (!merging)
1559
+ return;
1560
+ const detail = redactGitToken(abortErr instanceof Error ? abortErr.message : String(abortErr), this.credentials.token());
1561
+ log.error(`a merge in ${cwd} could not be undone; the checkout is left mid-merge:`, { detail });
1562
+ const lead = after === undefined ? 'the merge could not be undone' : `${after instanceof Error ? after.message : String(after)}; and the merge could not be undone`;
1563
+ throw new Error(`${lead}: ${detail}`, { cause: after ?? abortErr });
1564
+ }
1565
+ }
1566
+ /** The merge commit, as `user`, and its publish when asked for. The caller settles a failure. */
1567
+ async commitMerge(workspaceId, cwd, branch, fromBranch, user, opts, resolvedPaths) {
1568
+ await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', opts.subject, '-m', opts.body]);
1569
+ this.accessControl?.invalidate(workspaceId);
1570
+ // The publish runs here, inside the reservation the merge was made in,
1571
+ // over this checkout's own push and pull-rebase (the merge kept as a
1572
+ // merge) — the caller's policy, the git layer's moves. Whatever it
1573
+ // throws leaves the merge commit on the branch locally; a replay that
1574
+ // conflicts aborts itself (`rebaseOntoRemote`), so the checkout is clean.
1575
+ if (opts.publish) {
1576
+ await opts.publish({
1577
+ push: () => this.pushUnlocked(workspaceId, cwd, user),
1578
+ pullRebase: () => this.pullUnlocked(workspaceId, cwd, { preserveMerges: true }),
1579
+ });
1580
+ }
1581
+ // Read AFTER the publish: a push the host rejected is retried with the
1582
+ // local commits replayed onto the moved branch, which gives the merge
1583
+ // commit a new id — the one that is now the published tip.
1584
+ const sha = (await this.git(cwd, ['rev-parse', 'HEAD'])).stdout.trim();
1585
+ return { kind: 'merged', sha, from: fromBranch, resolvedPaths };
1586
+ }
1587
+ /**
1588
+ * One conflicted path as the merge on disk has it: the three index stages
1589
+ * (base, ours, theirs) and the working copy with git's markers. A side that
1590
+ * is not there (a delete on that side) is null; one that is not text or too
1591
+ * large to carry is left out and marked, for the caller to read at the
1592
+ * commit instead. Read while the merge is on disk — the stages exist only
1593
+ * then.
1594
+ */
1595
+ async describeConflict(cwd, repoRelativePath) {
1596
+ const truncated = [];
1597
+ const side = async (name, stage) => {
1598
+ let bytes;
1599
+ try {
1600
+ bytes = (await this.gitRunner.run(cwd, ['show', `:${stage}:${repoRelativePath}`], { encoding: 'buffer' })).stdout;
1601
+ }
1602
+ catch (err) {
1603
+ // ONLY a stage git says is not there is a side that has no file (that
1604
+ // side deleted it; the base never had it). Anything else — a deadline,
1605
+ // a repository failure — is not "deleted", and reporting it as such
1606
+ // would have a resolver drop a file over a hiccup.
1607
+ const msg = err.stderr ?? (err instanceof Error ? err.message : String(err));
1608
+ 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))
1609
+ return null;
1610
+ throw err;
1611
+ }
1612
+ return this.carriedText(bytes, () => truncated.push(name));
1613
+ };
1614
+ const base = await side('base', 1);
1615
+ const ours = await side('ours', 2);
1616
+ const theirs = await side('theirs', 3);
1617
+ let merged = null;
1618
+ // The working copy is read from disk, so it is read only where that
1619
+ // stays inside the checkout: a link (the path itself, or a folder on
1620
+ // the way to it) is never followed — the side is left out instead.
1621
+ const file = path.join(cwd, repoRelativePath);
1622
+ const leaf = await lstatOrNull(file);
1623
+ if (leaf?.isSymbolicLink() || (await linkedAncestor(cwd, path.dirname(file))) !== null) {
1624
+ truncated.push('merged');
1625
+ }
1626
+ else {
1627
+ try {
1628
+ merged = this.carriedText(await fs.readFile(file), () => truncated.push('merged'));
1629
+ }
1630
+ catch (err) {
1631
+ if (!isAbsence(err))
1304
1632
  throw err;
1305
- return { kind: 'conflicts', paths: conflictPaths };
1306
- }
1307
- // No conflicts. Detect "already up to date" — git prints that exact
1308
- // phrase and stages nothing. Otherwise the merge is staged and waiting
1309
- // for the commit we now author as `user`.
1310
- if (/Already up to date/i.test(mergeStdout)) {
1311
- return { kind: 'clean', alreadyUpToDate: true };
1312
- }
1313
- const subject = `Refresh ${sourceBranch} from ${targetBranch}`;
1314
- const body = `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
1315
- `as part of opening or refreshing a change request. ` +
1316
- `Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
1317
- `none were detected, so the merge applied cleanly.`;
1318
- await this.git(cwd, [
1319
- 'commit',
1320
- `--author=${user.name} <${user.email}>`,
1321
- '-m', subject,
1322
- '-m', body,
1323
- ]);
1324
- this.accessControl?.invalidate(workspaceId);
1325
- return { kind: 'clean', alreadyUpToDate: false };
1633
+ }
1634
+ }
1635
+ return { path: repoRelativePath, base, ours, theirs, merged, ...(truncated.length > 0 ? { truncated } : {}) };
1636
+ }
1637
+ /**
1638
+ * `bytes` as text when they are text by the one rule every reader applies
1639
+ * (`utf8Text`: no NUL, valid UTF-8, BOM kept) and small enough to carry;
1640
+ * else null, and `left` is told.
1641
+ */
1642
+ carriedText(bytes, left) {
1643
+ const text = bytes.length > GitService.CONFLICT_SIDE_MAX_CHARS ? null : utf8Text(bytes);
1644
+ if (text === null)
1645
+ left();
1646
+ return text;
1647
+ }
1648
+ /**
1649
+ * Settle one conflicted path on disk and in the index, as the resolution
1650
+ * says. `r.path` is one the merge itself listed as conflicted (matched
1651
+ * exactly by the caller), so it is a path git owns inside the checkout.
1652
+ * A side kept whole is restored by git from its index stage, byte for
1653
+ * byte — never through a text decoding — so a binary file survives it.
1654
+ */
1655
+ async applyResolution(cwd, r) {
1656
+ const file = path.join(cwd, r.path);
1657
+ // Written and removed where that stays inside the checkout: a folder on
1658
+ // the way that is a link is refused, and a path that is itself a link is
1659
+ // replaced as a path (the link removed first), never written through.
1660
+ if ((await linkedAncestor(cwd, path.dirname(file))) !== null) {
1661
+ throw new WorkflowValidationError(`"${r.path}" is reached through a link, so it is not resolved here`);
1662
+ }
1663
+ if (r.content !== undefined) {
1664
+ if ((await lstatOrNull(file))?.isSymbolicLink())
1665
+ await fs.unlink(file);
1666
+ await fs.mkdir(path.dirname(file), { recursive: true });
1667
+ await fs.writeFile(file, r.content, 'utf-8');
1668
+ await this.git(cwd, ['add', '--', r.path]);
1669
+ return;
1670
+ }
1671
+ if (r.choose === 'delete') {
1672
+ // `rm` removes a link itself, never what it points at.
1673
+ await fs.rm(file, { force: true });
1674
+ await this.git(cwd, ['add', '-A', '--', r.path]);
1675
+ return;
1676
+ }
1677
+ // `ours`/`theirs`: git checks that side's stage out as it is. A side with
1678
+ // no stage (deleted there) is "does not have our/their version": the
1679
+ // file goes.
1680
+ try {
1681
+ await this.git(cwd, ['checkout', r.choose === 'ours' ? '--ours' : '--theirs', '--', r.path]);
1682
+ }
1683
+ catch (err) {
1684
+ const msg = err instanceof Error ? err.message : String(err);
1685
+ if (!/does not have (our|their) version/i.test(msg))
1686
+ throw err;
1687
+ await fs.rm(file, { force: true });
1688
+ }
1689
+ await this.git(cwd, ['add', '-A', '--', r.path]);
1690
+ }
1691
+ async readFileAtBranch(workspaceId, relativePath, branch, authorize) {
1692
+ assertValidRelativePath(relativePath);
1693
+ assertValidBranchName(branch);
1694
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1695
+ return this.mutex.run(workspaceId, async () => {
1696
+ const cwd = await this.repoDir(workspaceId);
1697
+ // The PUBLISHED tip: what the branch's own workspace holds and has not
1698
+ // shared is nobody else's to read, so `origin/<branch>` comes first and
1699
+ // the local head serves only a branch origin does not have.
1700
+ const { ref, sha } = await this.resolvePublishedBranch(cwd, branch);
1701
+ // The caller's verdict, at every commit the branch resolves to here
1702
+ // (the served one first), BEFORE anything is said about the path at
1703
+ // it: a refusal that names what the path is (a folder) is an answer
1704
+ // about content the caller may not read.
1705
+ if (authorize)
1706
+ await authorize(await this.branchCommits(cwd, branch, sha));
1707
+ // Per file, as every history read: a folder's content at a commit is
1708
+ // every child's, which the per-file gate never checked.
1709
+ await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
1710
+ return { sha, ref, content: await this.readFileAtRef(workspaceId, sha, repoRelativePath) };
1326
1711
  });
1327
1712
  }
1328
1713
  /**
@@ -1703,17 +2088,25 @@ export class GitService {
1703
2088
  const { stdout } = await this.git(cwd, ['ls-remote', '--heads', 'origin']);
1704
2089
  return stdout.trim().length > 0;
1705
2090
  }
1706
- /** `git rev-parse --verify` that answers null for a rev that does not resolve (an unborn HEAD). */
2091
+ /**
2092
+ * `git rev-parse --verify --quiet`: the commit `rev` names, or null when
2093
+ * the rev is ABSENT (an unborn HEAD, a ref nobody made) — and nothing
2094
+ * else. Git says "absent" with exit 1 and an empty stderr; a lookup that
2095
+ * fails any other way (a corrupt repository, a deadline) throws, because
2096
+ * every caller reads null as a fact about the repository — "unborn",
2097
+ * "no such tip", "nothing to authorize at" — and a failure dressed as that
2098
+ * fact would make a sync reset an unborn clone or a verdict list lose a
2099
+ * commit.
2100
+ */
1707
2101
  async revParseOrNull(cwd, rev) {
1708
2102
  try {
1709
2103
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', rev]);
1710
2104
  return stdout.trim() || null;
1711
2105
  }
1712
2106
  catch (err) {
1713
- // Null is "no such rev"; a deadline is not that answer (see GitRunError).
1714
- if (isGitTimeout(err))
1715
- throw err;
1716
- return null;
2107
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && (err.stderr ?? '').trim() === '')
2108
+ return null;
2109
+ throw err;
1717
2110
  }
1718
2111
  }
1719
2112
  /**
@@ -1910,23 +2303,24 @@ export class GitService {
1910
2303
  });
1911
2304
  }
1912
2305
  async pull(workspaceId, opts = {}) {
1913
- return this.mutex.run(workspaceId, async () => {
1914
- const cwd = await this.repoDir(workspaceId);
1915
- const branch = await this.checkedOutBranch(cwd);
1916
- // For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
1917
- // a pull that lands only content-identical commits (an empty commit)
1918
- // must not read as a content change. Tolerant of an unborn HEAD (a
1919
- // clone of an empty upstream): "" before + a tree after correctly
1920
- // reads as changed, "" both sides as not.
1921
- const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1922
- // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
1923
- // `refreshRemoteBranchRef` for why both halves are load-bearing.
1924
- const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1925
- await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
1926
- this.accessControl?.invalidate(workspaceId);
1927
- const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
1928
- return { treeChanged: treeAfter !== treeBefore };
1929
- });
2306
+ return this.mutex.run(workspaceId, async () => this.pullUnlocked(workspaceId, await this.repoDir(workspaceId), opts));
2307
+ }
2308
+ /** `pull`'s body, for a caller that already holds the workspace's reservation. */
2309
+ async pullUnlocked(workspaceId, cwd, opts = {}) {
2310
+ const branch = await this.checkedOutBranch(cwd);
2311
+ // For `treeChanged` (see IGitService.pull): TREE ids, not commit ids —
2312
+ // a pull that lands only content-identical commits (an empty commit)
2313
+ // must not read as a content change. Tolerant of an unborn HEAD (a
2314
+ // clone of an empty upstream): "" before + a tree after correctly
2315
+ // reads as changed, "" both sides as not.
2316
+ const treeBefore = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2317
+ // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
2318
+ // `refreshRemoteBranchRef` for why both halves are load-bearing.
2319
+ const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
2320
+ await this.rebaseOntoRemote(cwd, branch, remoteRef, opts);
2321
+ this.accessControl?.invalidate(workspaceId);
2322
+ const treeAfter = (await this.revParseOrNull(cwd, 'HEAD^{tree}')) ?? '';
2323
+ return { treeChanged: treeAfter !== treeBefore };
1930
2324
  }
1931
2325
  /**
1932
2326
  * Does the checked-out branch carry commits its remote-tracking ref
@@ -2351,7 +2745,7 @@ export class GitService {
2351
2745
  return { baseline, current };
2352
2746
  });
2353
2747
  }
2354
- async diffFileBetweenBranches(workspaceId, relativePath, fromBranch, toBranch) {
2748
+ async diffFileBetweenBranches(workspaceId, relativePath, fromBranch, toBranch, authorize) {
2355
2749
  assertValidRelativePath(relativePath);
2356
2750
  assertValidBranchName(fromBranch);
2357
2751
  assertValidBranchName(toBranch);
@@ -2361,8 +2755,21 @@ export class GitService {
2361
2755
  const repoRelativePath = this.stripRepoPrefix(relativePath);
2362
2756
  return this.mutex.run(workspaceId, async () => {
2363
2757
  const cwd = await this.repoDir(workspaceId);
2364
- const fromRef = await this.resolveBranchRef(cwd, fromBranch);
2365
- const toRef = await this.resolveBranchRef(cwd, toBranch);
2758
+ // Each side's COMMIT, read once here and diffed from here on: what the
2759
+ // caller authorizes the answer at. The ref is a name a fetch may move.
2760
+ const fromRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, fromBranch)])).stdout.trim();
2761
+ const toRef = (await this.git(cwd, ['rev-parse', await this.resolveBranchRef(cwd, toBranch)])).stdout.trim();
2762
+ const pinned = (diff) => ({ diff, from: fromRef, to: toRef });
2763
+ // The caller's verdict at every commit each side resolves to here (the
2764
+ // served one first) BEFORE anything is said about the path at either:
2765
+ // a refusal that names what the path is there (a folder) is an answer
2766
+ // about content the caller may not read.
2767
+ if (authorize) {
2768
+ await authorize({
2769
+ from: await this.branchCommits(cwd, fromBranch, fromRef),
2770
+ to: await this.branchCommits(cwd, toBranch, toRef),
2771
+ });
2772
+ }
2366
2773
  await this.assertNotTreeAtRef(cwd, fromRef, repoRelativePath, relativePath);
2367
2774
  await this.assertNotTreeAtRef(cwd, toRef, repoRelativePath, relativePath);
2368
2775
  // When one side is the currently-checked-out branch, diff against the
@@ -2380,7 +2787,7 @@ export class GitService {
2380
2787
  // same pattern diffFileWorking uses for HEAD-vs-working-tree.
2381
2788
  if (isToCurrent !== isFromCurrent &&
2382
2789
  (await this.isPathUntracked(cwd, repoRelativePath))) {
2383
- return synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed');
2790
+ return pinned(await synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed'));
2384
2791
  }
2385
2792
  const args = ['diff', '--no-color'];
2386
2793
  if (isToCurrent && !isFromCurrent) {
@@ -2404,7 +2811,7 @@ export class GitService {
2404
2811
  args.push('--', repoRelativePath);
2405
2812
  try {
2406
2813
  const { stdout } = await this.git(cwd, args);
2407
- return stdout;
2814
+ return pinned(stdout);
2408
2815
  }
2409
2816
  catch (err) {
2410
2817
  const msg = err instanceof Error ? err.message : String(err);
@@ -2745,9 +3152,8 @@ export class GitService {
2745
3152
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
2746
3153
  }
2747
3154
  return this.mutex.run(workspaceId, async () => {
2748
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
2749
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
2750
- return this.prChangedPathsAt(cwd, baseRef, headRef, opts);
3155
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3156
+ return flattenChangedPaths(await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head), opts);
2751
3157
  });
2752
3158
  }
2753
3159
  /**
@@ -2800,12 +3206,49 @@ export class GitService {
2800
3206
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
2801
3207
  }
2802
3208
  return this.mutex.run(workspaceId, async () => {
2803
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
2804
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
2805
- const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
3209
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3210
+ const entries = await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head);
2806
3211
  return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
2807
3212
  });
2808
3213
  }
3214
+ /**
3215
+ * {@link prChangedEntriesAt} for a pair of COMMITS, read once per pair
3216
+ * (see `changedEntriesByCommits`). The caller holds the reservation, as for
3217
+ * `prChangedEntriesAt`; a memo hit holds it for nothing, which is cheaper
3218
+ * than a second code path around the lock.
3219
+ */
3220
+ async prChangedEntriesBetweenCommits(cwd, baseSha, headSha) {
3221
+ const key = `${baseSha}\0${headSha}`;
3222
+ const known = this.changedEntriesByCommits.get(key);
3223
+ if (known)
3224
+ return known;
3225
+ let pending = this.changedEntriesInFlight.get(key);
3226
+ if (!pending) {
3227
+ pending = this.prChangedEntriesAt(cwd, baseSha, headSha)
3228
+ .then((entries) => {
3229
+ if (this.changedEntriesByCommits.size >= GitService.CHANGED_ENTRIES_MAX) {
3230
+ const oldest = this.changedEntriesByCommits.keys().next().value;
3231
+ if (oldest !== undefined)
3232
+ this.changedEntriesByCommits.delete(oldest);
3233
+ }
3234
+ this.changedEntriesByCommits.set(key, entries);
3235
+ return entries;
3236
+ })
3237
+ .finally(() => {
3238
+ if (this.changedEntriesInFlight.get(key) === pending)
3239
+ this.changedEntriesInFlight.delete(key);
3240
+ });
3241
+ // Same bound as the memo: evicting an in-flight entry costs at most one
3242
+ // redundant diff for that pair, never a lost answer.
3243
+ if (this.changedEntriesInFlight.size >= GitService.CHANGED_ENTRIES_MAX) {
3244
+ const oldest = this.changedEntriesInFlight.keys().next().value;
3245
+ if (oldest !== undefined)
3246
+ this.changedEntriesInFlight.delete(oldest);
3247
+ }
3248
+ this.changedEntriesInFlight.set(key, pending);
3249
+ }
3250
+ return pending;
3251
+ }
2809
3252
  /**
2810
3253
  * The merge-base commit of a change request's two branches (their origin
2811
3254
  * refs), or null when they share no history. This is the "before" the CR's
@@ -3103,18 +3546,20 @@ export class GitService {
3103
3546
  * broken for some other reason.
3104
3547
  */
3105
3548
  async publishedPrCommits(cwd, baseBranch, headBranch) {
3106
- const shas = [];
3107
- for (const branch of [baseBranch, headBranch]) {
3108
- try {
3109
- shas.push((await this.resolvePublishedBranch(cwd, branch)).sha);
3110
- }
3111
- catch (err) {
3112
- if (err instanceof WorkflowValidationError)
3113
- return null;
3114
- throw err;
3115
- }
3549
+ try {
3550
+ return await this.publishedPrEnds(cwd, baseBranch, headBranch);
3551
+ }
3552
+ catch (err) {
3553
+ if (err instanceof WorkflowValidationError)
3554
+ return null;
3555
+ throw err;
3116
3556
  }
3117
- return { base: shas[0], head: shas[1] };
3557
+ }
3558
+ /** The two commits a change request is read between, each resolved as {@link resolvePublishedBranch} does. */
3559
+ async publishedPrEnds(cwd, baseBranch, headBranch) {
3560
+ const base = (await this.resolvePublishedBranch(cwd, baseBranch)).sha;
3561
+ const head = (await this.resolvePublishedBranch(cwd, headBranch)).sha;
3562
+ return { base, head };
3118
3563
  }
3119
3564
  /**
3120
3565
  * `resolveBranchRef` with the preference reversed: the PUBLISHED ref
@@ -3135,6 +3580,15 @@ export class GitService {
3135
3580
  */
3136
3581
  async resolvePublishedBranch(cwd, branch) {
3137
3582
  for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
3583
+ // The ref file first (`readRefSha`): no process, same answer, same
3584
+ // freshness. Git is asked only for what the files cannot decide — a
3585
+ // missing ref is decided by the files and the loop moves on; an
3586
+ // unreadable one is git's to answer, as the authority.
3587
+ const fromFiles = await readRefSha(cwd, ref);
3588
+ if (fromFiles === 'missing')
3589
+ continue;
3590
+ if (fromFiles !== 'unreadable')
3591
+ return { ref, sha: fromFiles.sha };
3138
3592
  try {
3139
3593
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
3140
3594
  return { ref, sha: stdout.trim() };
@@ -3150,6 +3604,26 @@ export class GitService {
3150
3604
  }
3151
3605
  throw new WorkflowValidationError(`unknown branch: ${branch}`);
3152
3606
  }
3607
+ /**
3608
+ * Every commit `branch` resolves to in this repository — its local head
3609
+ * and its published tip (`origin/<branch>`), whichever resolve, deduped,
3610
+ * `served` first. What a caller's read verdict is asked at: content of
3611
+ * another branch is served from one of them, and a grant at the served one
3612
+ * must not outrun a denial at the other (a draft's unshared commits may
3613
+ * carry rules its published tip does not, and the other way round) — the
3614
+ * rule the compare route always applied to both refs, pinned to commits.
3615
+ */
3616
+ async branchCommits(cwd, branch, served) {
3617
+ const commits = [served];
3618
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
3619
+ // Null is ABSENT and nothing else (`revParseOrNull`): a lookup that
3620
+ // fails otherwise throws, so this list never quietly loses a commit.
3621
+ const sha = await this.revParseOrNull(cwd, ref);
3622
+ if (sha && !commits.includes(sha))
3623
+ commits.push(sha);
3624
+ }
3625
+ return commits;
3626
+ }
3153
3627
  /**
3154
3628
  * Resolve a branch name to a concrete ref the local clone knows about.
3155
3629
  * Tries the local head first, falls back to the matching remote-tracking
@@ -3558,7 +4032,7 @@ export class GitService {
3558
4032
  * from a real failure are unaffected.
3559
4033
  */
3560
4034
  git(cwd, args, opts) {
3561
- return this.gitRunner.run(cwd, args, {
4035
+ const run = () => this.gitRunner.run(cwd, args, {
3562
4036
  ...opts,
3563
4037
  // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
3564
4038
  // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as glob
@@ -3573,6 +4047,17 @@ export class GitService {
3573
4047
  // a search for a file literally named that.
3574
4048
  env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
3575
4049
  });
4050
+ // A write to the git config is a write to the ONE file every worktree of
4051
+ // the repository shares, which git refuses a second writer of rather than
4052
+ // waiting: it runs as the repository's administrator. Everything else —
4053
+ // a fetch, a commit, a branch delete — git serializes with its own locks,
4054
+ // as it does for any two worktrees of one repository.
4055
+ return writesSharedConfig(args) ? this.asRepositoryAdministrator(cwd, run) : run();
4056
+ }
4057
+ /** `run` as the repository's administrator when `cwd` is a worktree of it; as itself for a full clone. */
4058
+ async asRepositoryAdministrator(cwd, run) {
4059
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
4060
+ return repository ? repository.exclusive(run) : run();
3576
4061
  }
3577
4062
  /**
3578
4063
  * {@link git}, with stdout as raw BYTES.
@@ -3746,4 +4231,36 @@ export function parseNumstatZ(out) {
3746
4231
  }
3747
4232
  return entries;
3748
4233
  }
4234
+ /**
4235
+ * Whether a git command WRITES the config — the one file every worktree of
4236
+ * the knowledge base's repository shares, which git refuses a second writer
4237
+ * of rather than waiting — and so must run as the repository's administrator
4238
+ * (`IKbRepository.exclusive`): a `config` that sets or unsets, a `remote`
4239
+ * change, a branch rename, delete or upstream change (its config section is
4240
+ * written, or removed with the branch), a push that sets the upstream. Reads
4241
+ * of the config are not writes. Nothing else is: the refs, `packed-refs` and
4242
+ * the object store are locked by git itself, and the worktree list is the
4243
+ * repository's own to serialize (`KbRepository`).
4244
+ *
4245
+ * Leading `-c key=value` / `-C dir` options are skipped to find the subcommand.
4246
+ */
4247
+ function writesSharedConfig(args) {
4248
+ let i = 0;
4249
+ while (i < args.length && (args[i] === '-c' || args[i] === '-C'))
4250
+ i += 2;
4251
+ const sub = args[i];
4252
+ const rest = args.slice(i + 1);
4253
+ switch (sub) {
4254
+ case 'remote':
4255
+ return true;
4256
+ case 'config':
4257
+ return !rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l|--show-origin)$/.test(a));
4258
+ case 'branch':
4259
+ return rest.some((a) => /^(-d|-D|--delete|-m|-M|--move|-u|--set-upstream-to=.*|--unset-upstream)$/.test(a));
4260
+ case 'push':
4261
+ return rest.some((a) => a === '-u' || a === '--set-upstream');
4262
+ default:
4263
+ return false;
4264
+ }
4265
+ }
3749
4266
  //# sourceMappingURL=git.service.js.map