@bevel-software/platform-core-backend 0.25.0 → 0.26.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 (254) hide show
  1. package/agent-guide/access-control.md +234 -0
  2. package/agent-guide/conventions.md +27 -0
  3. package/agent-guide/directory-structure.md +145 -0
  4. package/agent-guide/finding-things.md +7 -0
  5. package/agent-guide/introduction.md +27 -0
  6. package/agent-guide/skills.md +47 -0
  7. package/agent-guide/tool-manuals.md +217 -0
  8. package/agent-guide/where-a-new-file-goes.md +36 -0
  9. package/dist/assets.d.ts +7 -0
  10. package/dist/assets.d.ts.map +1 -1
  11. package/dist/assets.js +9 -0
  12. package/dist/assets.js.map +1 -1
  13. package/dist/core/core-ports.d.ts +11 -0
  14. package/dist/core/core-ports.d.ts.map +1 -1
  15. package/dist/core/core-ports.js.map +1 -1
  16. package/dist/core/create-core-server.d.ts.map +1 -1
  17. package/dist/core/create-core-server.js +13 -2
  18. package/dist/core/create-core-server.js.map +1 -1
  19. package/dist/core/create-core-services.d.ts +9 -0
  20. package/dist/core/create-core-services.d.ts.map +1 -1
  21. package/dist/core/create-core-services.js +14 -4
  22. package/dist/core/create-core-services.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/modules/access/access-control.interface.d.ts +9 -0
  28. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  29. package/dist/modules/access/access-control.service.d.ts +1 -0
  30. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  31. package/dist/modules/access/access-control.service.js +16 -0
  32. package/dist/modules/access/access-control.service.js.map +1 -1
  33. package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
  34. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
  35. package/dist/modules/agent-guide/agent-guide.js +191 -0
  36. package/dist/modules/agent-guide/agent-guide.js.map +1 -0
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
  38. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
  39. package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
  40. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
  41. package/dist/modules/agent-guide/index.d.ts +4 -0
  42. package/dist/modules/agent-guide/index.d.ts.map +1 -0
  43. package/dist/modules/agent-guide/index.js +4 -0
  44. package/dist/modules/agent-guide/index.js.map +1 -0
  45. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
  46. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  47. package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
  48. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  49. package/dist/modules/agent-instructions/compose.d.ts +9 -6
  50. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  51. package/dist/modules/agent-instructions/compose.js +9 -6
  52. package/dist/modules/agent-instructions/compose.js.map +1 -1
  53. package/dist/modules/agent-instructions/index.d.ts +1 -1
  54. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  55. package/dist/modules/agent-instructions/index.js +1 -1
  56. package/dist/modules/agent-instructions/index.js.map +1 -1
  57. package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
  58. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  59. package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
  60. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  61. package/dist/modules/mcp/mcp.service.d.ts +29 -2
  62. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  63. package/dist/modules/mcp/mcp.service.js +113 -16
  64. package/dist/modules/mcp/mcp.service.js.map +1 -1
  65. package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
  66. package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
  67. package/dist/modules/mcp/tool-schema-guard.js +171 -0
  68. package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
  69. package/dist/modules/plugins/plugins.tools.d.ts +36 -2
  70. package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
  71. package/dist/modules/plugins/plugins.tools.js +71 -14
  72. package/dist/modules/plugins/plugins.tools.js.map +1 -1
  73. package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
  74. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  75. package/dist/modules/settings/deployment-settings.service.js +14 -53
  76. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  77. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  78. package/dist/modules/settings/setup.routes.js +3 -6
  79. package/dist/modules/settings/setup.routes.js.map +1 -1
  80. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +58 -16
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
  84. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  85. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
  86. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
  87. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  88. package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
  89. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  90. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
  91. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
  93. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  94. package/dist/modules/tool-registry/description-length.d.ts +14 -14
  95. package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
  96. package/dist/modules/tool-registry/description-length.js +24 -26
  97. package/dist/modules/tool-registry/description-length.js.map +1 -1
  98. package/dist/modules/tool-registry/guide-first.d.ts +23 -0
  99. package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
  100. package/dist/modules/tool-registry/guide-first.js +32 -0
  101. package/dist/modules/tool-registry/guide-first.js.map +1 -0
  102. package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
  103. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
  104. package/dist/modules/tool-registry/tool-registry.js +9 -2
  105. package/dist/modules/tool-registry/tool-registry.js.map +1 -1
  106. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
  107. package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
  108. package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
  109. package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
  110. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
  114. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
  115. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
  117. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
  118. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  119. package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  121. package/dist/modules/workflow/git/git.service.d.ts +210 -13
  122. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  123. package/dist/modules/workflow/git/git.service.js +456 -91
  124. package/dist/modules/workflow/git/git.service.js.map +1 -1
  125. package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
  126. package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
  127. package/dist/modules/workflow/git/merge-commit.js +89 -0
  128. package/dist/modules/workflow/git/merge-commit.js.map +1 -0
  129. package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
  130. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/git/pull-request.service.js +332 -37
  132. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  133. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
  134. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  135. package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
  136. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  137. package/dist/modules/workflow/workflow.routes.d.ts +6 -2
  138. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  139. package/dist/modules/workflow/workflow.routes.js +7 -2
  140. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  141. package/dist/modules/workflow/workflow.service.d.ts +4 -0
  142. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  143. package/dist/modules/workflow/workflow.service.js +3 -0
  144. package/dist/modules/workflow/workflow.service.js.map +1 -1
  145. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  146. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  147. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  148. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  149. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  150. package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
  151. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  152. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
  153. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  154. package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
  155. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  156. package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
  157. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/steps/template-source.js +5 -3
  159. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  160. package/dist/modules/workspace/workspace.tools.d.ts +10 -1
  161. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  162. package/dist/modules/workspace/workspace.tools.js +211 -18
  163. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  164. package/dist/shared/domain-errors.d.ts +11 -0
  165. package/dist/shared/domain-errors.d.ts.map +1 -1
  166. package/dist/shared/domain-errors.js +14 -0
  167. package/dist/shared/domain-errors.js.map +1 -1
  168. package/dist/shared/hidden-tools.d.ts +44 -0
  169. package/dist/shared/hidden-tools.d.ts.map +1 -0
  170. package/dist/shared/hidden-tools.js +13 -0
  171. package/dist/shared/hidden-tools.js.map +1 -0
  172. package/kb-template/.bevelignore +0 -5
  173. package/package.json +4 -3
  174. package/src/__tests__/kb-layout-config.test.ts +10 -100
  175. package/src/__tests__/packaged-assets-ship.test.ts +54 -0
  176. package/src/assets.ts +10 -0
  177. package/src/core/core-ports.ts +11 -0
  178. package/src/core/create-core-server.ts +13 -2
  179. package/src/core/create-core-services.ts +28 -4
  180. package/src/index.ts +2 -2
  181. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
  182. package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
  183. package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
  184. package/src/modules/access/access-control.interface.ts +15 -0
  185. package/src/modules/access/access-control.service.ts +21 -0
  186. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
  187. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
  188. package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
  189. package/src/modules/agent-guide/agent-guide.ts +291 -0
  190. package/src/modules/agent-guide/index.ts +21 -0
  191. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
  192. package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
  193. package/src/modules/agent-instructions/compose.ts +9 -6
  194. package/src/modules/agent-instructions/index.ts +0 -3
  195. package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
  196. package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
  197. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
  198. package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
  199. package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
  200. package/src/modules/mcp/mcp.service.ts +137 -19
  201. package/src/modules/mcp/tool-schema-guard.ts +196 -0
  202. package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
  203. package/src/modules/plugins/plugins.tools.ts +75 -15
  204. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
  205. package/src/modules/settings/deployment-settings.service.ts +13 -54
  206. package/src/modules/settings/setup.routes.ts +3 -6
  207. package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
  208. package/src/modules/skills/skills.tools.ts +62 -16
  209. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
  210. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
  211. package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
  212. package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
  213. package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
  214. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
  215. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
  216. package/src/modules/tool-registry/description-length.ts +24 -26
  217. package/src/modules/tool-registry/guide-first.ts +34 -0
  218. package/src/modules/tool-registry/tool-registry.ts +9 -2
  219. package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
  220. package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
  221. package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
  222. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
  223. package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
  224. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
  225. package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
  226. package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
  227. package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
  228. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  229. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
  230. package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
  231. package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
  232. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
  233. package/src/modules/workflow/git/git.service.ts +537 -94
  234. package/src/modules/workflow/git/merge-commit.ts +88 -0
  235. package/src/modules/workflow/git/pull-request.service.ts +380 -54
  236. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
  237. package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
  238. package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
  239. package/src/modules/workflow/workflow.routes.ts +7 -2
  240. package/src/modules/workflow/workflow.service.ts +7 -0
  241. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
  242. package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
  243. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
  244. package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
  245. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  246. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  247. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
  248. package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
  249. package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
  250. package/src/modules/workspace/startup/steps/template-source.ts +5 -3
  251. package/src/modules/workspace/workspace.tools.ts +226 -16
  252. package/src/shared/domain-errors.ts +15 -0
  253. package/src/shared/hidden-tools.ts +45 -0
  254. package/kb-template/AGENTS.md +0 -730
@@ -2,13 +2,14 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { logger } from '../../../shared/logging.js';
4
4
  import { isFolderPlaceholder } from '@bevel-software/platform-shared';
5
+ import { mergeCommitMessageNames } from './merge-commit.js';
5
6
  import { AccessDeniedError } from '../../access-model/access-errors.js';
6
7
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
7
8
  import { isAbsence } from '../../../shared/fs.contract.js';
8
9
  import { assertInsideRepo } from '../../kb-fs/repo-path.js';
9
10
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
10
11
  import { assertValidBranchName, assertValidRelativePath, isBranchAuthoredBy, isOwnSuggestionsBranch, } from '../../kb-fs/branch-name.js';
11
- import { BranchAuthorshipError, WorkflowDomainError, WorkflowValidationError, ProtectedBranchError, PullRebaseConflictError, RemoteBranchGoneError, VersionNotOnBranchError, isMissingRemoteBranchFailure, } from '../../../shared/domain-errors.js';
12
+ import { AppliedChangeMismatchError, BranchAuthorshipError, WorkflowDomainError, WorkflowValidationError, ProtectedBranchError, PullRebaseConflictError, RemoteBranchGoneError, VersionNotOnBranchError, isMissingRemoteBranchFailure, } from '../../../shared/domain-errors.js';
12
13
  import { GitRunError, assertOneSpecPerLine, isGitTimeout, redactGitToken, } from '../../../shared/git.contract.js';
13
14
  import { NodeGitRunner } from './node-git-runner.js';
14
15
  import { printable } from '../../../shared/printable.js';
@@ -1250,12 +1251,23 @@ export class GitService {
1250
1251
  * the new base and re-merge on top of it. Conflicts are deterministic — they
1251
1252
  * return immediately (no retry) so the caller can route into resolution.
1252
1253
  *
1253
- * Returns the merge commit SHA, or the conflicting paths.
1254
+ * Returns the state the target is left at (`sha`) together with the merge
1255
+ * commit this change request owns (`mergeCommit`, null when there is none), or
1256
+ * the conflicting paths.
1254
1257
  */
1255
1258
  async mergeChangeRequest(baseWorkspaceId, sourceBranch, targetBranch, commit, user, opts = {}) {
1256
1259
  assertValidBranchName(sourceBranch);
1257
1260
  assertValidBranchName(targetBranch);
1258
1261
  assertValidAuthor(user);
1262
+ // The same single-line rule `commit` applies, and for a sharper reason here:
1263
+ // `git commit -m` takes the first PARAGRAPH of this string as the subject, so
1264
+ // a blank line inside it would silently push everything after the break into
1265
+ // the body — including the `(#N)` an applied request is read back by.
1266
+ // `mergeCommitSubject` already flattens the title it builds from; this is the
1267
+ // guard that keeps any other caller from reintroducing the break.
1268
+ if (/\n\s*\n/.test(commit.subject)) {
1269
+ throw new WorkflowValidationError('merge commit subject must be a single paragraph');
1270
+ }
1259
1271
  const MAX_ATTEMPTS = 3;
1260
1272
  return this.mutex.run(baseWorkspaceId, async () => {
1261
1273
  const cwd = await this.repoDir(baseWorkspaceId);
@@ -1320,13 +1332,28 @@ export class GitService {
1320
1332
  // (e.g. missing identity, unrelated histories) is diagnosable.
1321
1333
  throw new Error(`git merge failed without detectable conflicts: ${redactGitToken(err instanceof Error ? err.message : String(err), this.credentials.token())}`);
1322
1334
  }
1323
- // Nothing staged ⇒ base already contains source (empty CR). The base tip
1324
- // is the "merged" state; report it without an empty commit.
1335
+ // Nothing staged ⇒ the target already contains the source. The target tip
1336
+ // is the "merged" state; report it without an empty commit — but NOT as
1337
+ // this request's own merge commit. It is whatever landed on the target
1338
+ // last, usually another request's merge commit, and a reader that took it
1339
+ // for this request's would answer with that other request's files under
1340
+ // this number (cubic P1 on #347).
1341
+ //
1342
+ // There are two ways to arrive here, and they differ in exactly one
1343
+ // thing: whether this request has a merge commit on the target already.
1344
+ // An EMPTY request never had anything to land and has none. A request
1345
+ // whose previous attempt pushed its merge commit and then failed to
1346
+ // finalize the row (a transient database fault between the push and the
1347
+ // update) has one, and it is the only place its file list survives — so
1348
+ // it is looked for rather than assumed absent (cubic P2 on #347).
1325
1349
  const { stdout: staged } = await this.git(cwd, ['diff', '--cached', '--name-only']);
1326
1350
  if (staged.trim() === '') {
1327
1351
  const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
1328
1352
  await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
1329
- return { kind: 'merged', sha: sha.trim() };
1353
+ const own = opts.appliedChangeNumber === undefined
1354
+ ? null
1355
+ : await this.ownMergeCommitOn(cwd, `origin/${targetBranch}`, opts.appliedChangeNumber, opts.appliedChangeTitle);
1356
+ return { kind: 'merged', sha: sha.trim(), mergeCommit: own };
1330
1357
  }
1331
1358
  await this.git(cwd, [
1332
1359
  'commit',
@@ -1338,7 +1365,8 @@ export class GitService {
1338
1365
  try {
1339
1366
  await this.git(cwd, ['push', 'origin', `HEAD:refs/heads/${targetBranch}`]);
1340
1367
  this.accessControl?.invalidate(baseWorkspaceId);
1341
- return { kind: 'merged', sha: sha.trim() };
1368
+ // A commit of this request's own, with the subject the reader verifies.
1369
+ return { kind: 'merged', sha: sha.trim(), mergeCommit: sha.trim() };
1342
1370
  }
1343
1371
  catch (err) {
1344
1372
  const msg = err instanceof Error ? err.message : String(err);
@@ -1352,6 +1380,100 @@ export class GitService {
1352
1380
  throw new Error(`merge push to "${targetBranch}" kept being rejected after ${MAX_ATTEMPTS} attempts (base moving concurrently)`);
1353
1381
  });
1354
1382
  }
1383
+ /**
1384
+ * The merge commit change request `number` OWNS on `targetBranch`, or null when
1385
+ * the target carries none. Asks the published target only — no source branch,
1386
+ * no working tree, nothing merged, nothing written.
1387
+ *
1388
+ * This is the same question {@link mergeChangeRequest} answers for itself when
1389
+ * it finds nothing to merge, asked WITHOUT attempting a merge at all. The
1390
+ * merge path cannot be the only way to ask it: a request whose merge commit
1391
+ * was pushed by an attempt that then failed to record the row has an empty
1392
+ * diff against the target, so the approval gate refuses the retry ("no file
1393
+ * changes to approve") BEFORE any merge runs, and the recovery inside the
1394
+ * merge never gets its turn (cubic P2 on #347). The gate asks here instead.
1395
+ *
1396
+ * The target ref is refreshed first, because the attempt that pushed the
1397
+ * commit may have run in another process or another clone — and a fetch that
1398
+ * FAILS answers null without looking, which is the only safe answer: what the
1399
+ * caller does with a commit is record it as this request's published state, so
1400
+ * a decision taken on a stale tracking ref could finalize a request with a
1401
+ * commit the published branch no longer carries, and a clone that has no
1402
+ * tracking ref at all would turn the gate's clean refusal into a git error
1403
+ * (cubic P1 on #347). No authority, no answer; the caller then refuses exactly
1404
+ * as it did before this method existed.
1405
+ */
1406
+ async appliedMergeCommitOnTarget(baseWorkspaceId, targetBranch, number, title) {
1407
+ assertValidBranchName(targetBranch);
1408
+ const cwd = await this.repoDir(baseWorkspaceId);
1409
+ // The fetch runs outside the workspace mutex, the way the implicit and
1410
+ // per-request fetches do (see `fetchLocks`, `fetchPrRefs`): it writes only
1411
+ // `refs/remotes/origin/*` and new objects, never HEAD, the index or the
1412
+ // working tree, so nothing it does needs serializing against a checkout or
1413
+ // a commit, and holding the mutex through a network round trip would park
1414
+ // every other git op behind the origin. (`mergeChangeRequest` fetches under
1415
+ // the mutex for a reason this method lacks: its fetch is one step of a
1416
+ // merge whose working tree must not move between the fetch and the push.)
1417
+ // The read that follows is a local one, and takes the mutex like
1418
+ // `appliedChangeShas` does.
1419
+ const refreshed = await this.git(cwd, [
1420
+ 'fetch', '--no-write-fetch-head', 'origin',
1421
+ `+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
1422
+ ]).then(() => true, () => false);
1423
+ if (!refreshed) {
1424
+ log.warn(`could not refresh "${targetBranch}" to look for change request #${number}'s merge commit, ` +
1425
+ 'so it is reported as owning none.');
1426
+ return null;
1427
+ }
1428
+ return this.mutex.run(baseWorkspaceId, () => this.ownMergeCommitOn(cwd, `origin/${targetBranch}`, number, title));
1429
+ }
1430
+ /**
1431
+ * The newest merge commit reachable from `targetRef` that is change request
1432
+ * `number`'s OWN, or null if the target carries none.
1433
+ *
1434
+ * Asked when a merge finds nothing to merge, to tell "this request never had
1435
+ * anything to land" apart from "a previous attempt already landed it". The
1436
+ * second happens when the push succeeded and the row update did not — the
1437
+ * merge commit is on the target, and if this attempt then records no sha the
1438
+ * request becomes permanently fileless, readable by its author alone, with the
1439
+ * commit that holds its files still sitting there unreferenced (cubic P2 on
1440
+ * #347).
1441
+ *
1442
+ * The two conditions are the reader's own (`appliedChangeShas`): a merge
1443
+ * commit, whose subject names this number. Deciding it here with the same
1444
+ * predicate means the writer records only what the reader will accept. The
1445
+ * walk is bounded by `--grep` (the number, as a fixed string), which is the
1446
+ * whole of the target's history for commits mentioning the number — and not
1447
+ * by a count: a fixed count of newer merges once hid a stuck request's commit
1448
+ * from the probe on a busy target, and a request that could no longer be
1449
+ * finalized is worse than a log walk. The message check then confirms a
1450
+ * body-only mention is not the request's own. Never on an ordinary merge:
1451
+ * the two callers are the empty-merge case here and
1452
+ * {@link appliedMergeCommitOnTarget}, which the approval gate asks before it
1453
+ * refuses a retry that has nothing left to merge.
1454
+ *
1455
+ * `title` lets the old message format be recognised — see
1456
+ * {@link mergeCommitMessageNames}.
1457
+ */
1458
+ async ownMergeCommitOn(cwd, targetRef, number, title) {
1459
+ // The whole message per commit. Records are NUL-separated (`-z`), the one
1460
+ // byte a commit message cannot hold, and the sha is the record's first
1461
+ // line: nothing a title may contain can split a record or a line early.
1462
+ const { stdout } = await this.git(cwd, [
1463
+ 'log', '-z', '--merges', '--fixed-strings', `--grep=(#${number})`,
1464
+ '--format=%H%n%B', targetRef,
1465
+ ]);
1466
+ for (const record of stdout.split('\0')) {
1467
+ const newline = record.indexOf('\n');
1468
+ const sha = (newline === -1 ? record : record.slice(0, newline)).trim();
1469
+ if (!/^[0-9a-f]{40,64}$/.test(sha))
1470
+ continue;
1471
+ const message = newline === -1 ? '' : record.slice(newline + 1);
1472
+ if (mergeCommitMessageNames(message, number, title))
1473
+ return sha;
1474
+ }
1475
+ return null;
1476
+ }
1355
1477
  /** Conflicted paths from a half-done merge, parsed from porcelain status. */
1356
1478
  async conflictedPaths(cwd) {
1357
1479
  const { stdout } = await this.git(cwd, ['status', '--porcelain=v1']);
@@ -2216,70 +2338,234 @@ export class GitService {
2216
2338
  return this.mutex.run(workspaceId, async () => {
2217
2339
  const baseRef = opts.at ? opts.at.baseSha : await this.resolvePublishedBranchRef(cwd, baseBranch);
2218
2340
  const headRef = opts.at ? opts.at.headSha : await this.resolvePublishedBranchRef(cwd, headBranch);
2219
- const range = `${baseRef}...${headRef}`; // three-dot = changes on head since merge-base
2220
- const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
2221
- this.git(cwd, ['diff', '-M', '-z', '--name-status', range]),
2222
- this.git(cwd, ['diff', '-M', '-z', '--numstat', range]),
2223
- ]);
2224
- let statuses = parseNameStatusZ(nameStatusOut);
2225
- let counts = parseNumstatZ(numstatOut);
2226
- // roles.yaml can NEVER change through a merge — `preserveBaseRolesYaml`
2227
- // restores the base copy onto the source branch before every merge — so
2228
- // listing it as "changed" claims something the merge will not do, and
2229
- // (worse) makes its approval a requirement for a change that cannot
2230
- // land. Filter it from the review surface entirely; the neutralisation
2231
- // reads the raw refs itself and is unaffected. Both lists are filtered
2232
- // IN STEP so the index-zip below stays aligned.
2233
- // The empty-folder placeholder is filtered the same way: it is never
2234
- // content, so it is not a file to review or approve. It still merges
2235
- // with the rest, and `changedPathsForPr` keeps it, so a request that
2236
- // only creates a folder is not mistaken for an empty one and closed.
2237
- // An empty file deleted as its folder gets the (equally empty)
2238
- // placeholder reads to `-M` as a RENAME onto it; the real side of such
2239
- // a pair is first made the plain removal (or addition) it is, so
2240
- // dropping the placeholder never drops the file with it.
2241
- statuses = statuses.map(withoutPlaceholderRename);
2242
- if (statuses.some((s) => s.path === 'roles.yaml' || isFolderPlaceholder(s.path))) {
2243
- const keep = statuses.map((s) => s.path !== 'roles.yaml' && !isFolderPlaceholder(s.path));
2244
- statuses = statuses.filter((_, i) => keep[i]);
2245
- if (counts.length === keep.length)
2246
- counts = counts.filter((_, i) => keep[i]);
2247
- }
2248
- // `--name-status` and `--numstat` enumerate the same files in the same
2249
- // order (same `-M` over the same range), so we zip by index. If the two
2250
- // ever disagree in length, the index alignment is unsafe — fall back to
2251
- // zeroed counts (statuses/paths stay correct) rather than pin the wrong
2252
- // +/- to a file.
2253
- const aligned = counts.length === statuses.length;
2254
- if (!aligned) {
2255
- crLog.warn(`diff name-status/numstat length mismatch (${statuses.length} vs ${counts.length}) ` +
2256
- `for ${range} — reporting file list without +/- counts`);
2257
- }
2258
- const files = statuses.map((s, i) => {
2259
- const c = (aligned ? counts[i] : undefined) ?? { additions: 0, deletions: 0, isBinary: false };
2260
- return {
2261
- path: s.path,
2262
- previousPath: s.previousPath,
2263
- status: s.status,
2264
- additions: c.additions,
2265
- deletions: c.deletions,
2266
- patch: undefined,
2267
- isBinary: c.isBinary,
2268
- sha: '',
2269
- rawUrl: '',
2270
- };
2271
- });
2272
- // Generate per-file patches for the first `patchCap` non-binary files.
2273
- let generated = 0;
2274
- for (const f of files) {
2275
- if (generated >= patchCap)
2276
- break;
2277
- if (f.isBinary)
2278
- continue;
2279
- f.patch = await this.filePatchForPr(cwd, range, f);
2280
- generated += 1;
2341
+ // three-dot = changes on head since merge-base
2342
+ return this.prFilesForRange(cwd, `${baseRef}...${headRef}`, patchCap);
2343
+ });
2344
+ }
2345
+ /**
2346
+ * The changed-file list over one already-decided diff `range`, with the
2347
+ * statuses, the +/- counts and up to `patchCap` patches — everything
2348
+ * `changedFilesForPr` does once it knows which two commits it is comparing.
2349
+ *
2350
+ * Separated from the ref resolution because a change request has a second
2351
+ * diff that means exactly the same thing and resolves nothing: the one a
2352
+ * MERGE COMMIT holds, read against its first parent. Both must filter
2353
+ * `roles.yaml` and the folder placeholder the same way and zip the two
2354
+ * `--name-status` / `--numstat` lists by the same index, or a merged request
2355
+ * would answer a different file list from the one it answered while open.
2356
+ *
2357
+ * The caller holds the workspace reservation; this takes none.
2358
+ */
2359
+ async prFilesForRange(cwd, range, patchCap) {
2360
+ const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
2361
+ this.git(cwd, ['diff', '-M', '-z', '--name-status', range]),
2362
+ this.git(cwd, ['diff', '-M', '-z', '--numstat', range]),
2363
+ ]);
2364
+ let statuses = parseNameStatusZ(nameStatusOut);
2365
+ let counts = parseNumstatZ(numstatOut);
2366
+ // roles.yaml can NEVER change through a merge — `preserveBaseRolesYaml`
2367
+ // restores the base copy onto the source branch before every merge — so
2368
+ // listing it as "changed" claims something the merge will not do, and
2369
+ // (worse) makes its approval a requirement for a change that cannot
2370
+ // land. Filter it from the review surface entirely; the neutralisation
2371
+ // reads the raw refs itself and is unaffected. Both lists are filtered
2372
+ // IN STEP so the index-zip below stays aligned.
2373
+ // The empty-folder placeholder is filtered the same way: it is never
2374
+ // content, so it is not a file to review or approve. It still merges
2375
+ // with the rest, and `changedPathsForPr` keeps it, so a request that
2376
+ // only creates a folder is not mistaken for an empty one and closed.
2377
+ // An empty file deleted as its folder gets the (equally empty)
2378
+ // placeholder reads to `-M` as a RENAME onto it; the real side of such
2379
+ // a pair is first made the plain removal (or addition) it is, so
2380
+ // dropping the placeholder never drops the file with it.
2381
+ statuses = statuses.map(withoutPlaceholderRename);
2382
+ if (statuses.some((s) => s.path === 'roles.yaml' || isFolderPlaceholder(s.path))) {
2383
+ const keep = statuses.map((s) => s.path !== 'roles.yaml' && !isFolderPlaceholder(s.path));
2384
+ statuses = statuses.filter((_, i) => keep[i]);
2385
+ if (counts.length === keep.length)
2386
+ counts = counts.filter((_, i) => keep[i]);
2387
+ }
2388
+ // `--name-status` and `--numstat` enumerate the same files in the same
2389
+ // order (same `-M` over the same range), so we zip by index. If the two
2390
+ // ever disagree in length, the index alignment is unsafe — fall back to
2391
+ // zeroed counts (statuses/paths stay correct) rather than pin the wrong
2392
+ // +/- to a file.
2393
+ const aligned = counts.length === statuses.length;
2394
+ if (!aligned) {
2395
+ crLog.warn(`diff name-status/numstat length mismatch (${statuses.length} vs ${counts.length}) ` +
2396
+ `for ${range} — reporting file list without +/- counts`);
2397
+ }
2398
+ const files = statuses.map((s, i) => {
2399
+ const c = (aligned ? counts[i] : undefined) ?? { additions: 0, deletions: 0, isBinary: false };
2400
+ return {
2401
+ path: s.path,
2402
+ previousPath: s.previousPath,
2403
+ status: s.status,
2404
+ additions: c.additions,
2405
+ deletions: c.deletions,
2406
+ patch: undefined,
2407
+ isBinary: c.isBinary,
2408
+ sha: '',
2409
+ rawUrl: '',
2410
+ };
2411
+ });
2412
+ // Generate per-file patches for the first `patchCap` non-binary files.
2413
+ let generated = 0;
2414
+ for (const f of files) {
2415
+ if (generated >= patchCap)
2416
+ break;
2417
+ if (f.isBinary)
2418
+ continue;
2419
+ f.patch = await this.filePatchForPr(cwd, range, f);
2420
+ generated += 1;
2421
+ }
2422
+ return files;
2423
+ }
2424
+ /**
2425
+ * The FIRST PARENT of a commit, resolved — the other end of the diff one
2426
+ * commit introduced, and the check that this clone holds the commit at all.
2427
+ *
2428
+ * This is how an applied change request is read back. Its source branch is
2429
+ * retired, so there are no two branch refs left to diff; what is left is the
2430
+ * merge commit the row records, whose first parent is the target as it stood
2431
+ * before the merge and whose tree is the target with the change in it. The
2432
+ * diff between the two is precisely what the request applied — and it is
2433
+ * immutable, which the branch pair never was.
2434
+ *
2435
+ * First-parent and TWO dots, both deliberately. A three-dot diff would be
2436
+ * read from the merge base of the two parents, i.e. it would re-report the
2437
+ * source branch's whole history rather than what landed. `^1` is the target
2438
+ * side for a merge commit, and for a squashed or fast-forwarded commit it is
2439
+ * simply the commit before — the same answer either way.
2440
+ *
2441
+ * Throws `WorkflowValidationError` whenever the recorded commit cannot be
2442
+ * PROVEN to be this request's own merge commit — see
2443
+ * {@link appliedChangeShas}. Every caller reads that as "the file set could
2444
+ * not be resolved" and falls back to its own fail-closed answer, which is what
2445
+ * a clone that has not fetched the merge yet must get: no files, never
2446
+ * somebody else's.
2447
+ */
2448
+ async appliedEnds(cwd, applied) {
2449
+ const { mergeSha, number } = applied;
2450
+ if (!/^[0-9a-f]{40,64}$/.test(mergeSha)) {
2451
+ throw new WorkflowValidationError(`invalid commit sha: ${mergeSha}`);
2452
+ }
2453
+ const revParse = async (rev) => {
2454
+ try {
2455
+ // One question, both answers: `--verify --quiet` prints what it resolved
2456
+ // and exits 1 if there is nothing to resolve.
2457
+ const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', rev]);
2458
+ return stdout.trim();
2281
2459
  }
2282
- return files;
2460
+ catch (err) {
2461
+ // Exit 1 under --quiet is git's own "no such object": this clone does
2462
+ // not have the commit, or the commit has no such parent. Anything else
2463
+ // (a deadline, a broken repository) is the caller's to see.
2464
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1)
2465
+ return null;
2466
+ throw err;
2467
+ }
2468
+ };
2469
+ // Two refusals, told apart for the caller's sake. A commit this clone does
2470
+ // NOT HOLD is a passing state — the next fetch may bring it — and is
2471
+ // reported as plain validation failure, to be asked again. A commit the
2472
+ // clone holds that is NOT THIS REQUEST'S merge commit (no second parent,
2473
+ // or a message that does not name the request) never becomes it, and is
2474
+ // reported as a mismatch the caller may remember.
2475
+ if (!(await revParse(`${mergeSha}^{commit}`))) {
2476
+ throw new WorkflowValidationError(`commit ${mergeSha} is not in this clone, so change request #${number} cannot be read from it yet`);
2477
+ }
2478
+ // The SECOND parent first, because its absence is the whole P1: a merge
2479
+ // `--no-ff` always has one, and a commit that does not is not a merge this
2480
+ // request made.
2481
+ const headSha = await revParse(`${mergeSha}^2^{commit}`);
2482
+ if (!headSha) {
2483
+ throw new AppliedChangeMismatchError(`commit ${mergeSha} is not a merge commit, so it cannot be change request #${number}'s`);
2484
+ }
2485
+ const baseSha = await revParse(`${mergeSha}^1^{commit}`);
2486
+ if (!baseSha) {
2487
+ throw new AppliedChangeMismatchError(`no first parent for commit ${mergeSha}`);
2488
+ }
2489
+ const { stdout: message } = await this.git(cwd, ['log', '-1', '--format=%B', mergeSha]);
2490
+ if (!mergeCommitMessageNames(message, number, applied.title)) {
2491
+ throw new AppliedChangeMismatchError(`commit ${mergeSha} is not the merge commit of change request #${number}`);
2492
+ }
2493
+ return { baseSha, headSha };
2494
+ }
2495
+ /**
2496
+ * The two commits an APPLIED change request spanned, recovered from the merge
2497
+ * commit its row records: the target as it stood before the merge (`^1`) and
2498
+ * the source tip that was merged (`^2`).
2499
+ *
2500
+ * These are what the detail of a merged request reports as its `baseSha` and
2501
+ * `headSha`, and the `headSha` matters beyond being informative: an approval
2502
+ * row stores the source head it was given against, and the detail calls an
2503
+ * approval stale when that sha is not the detail's `headSha`. Answering the
2504
+ * merge commit itself there would report every approval a merged request ever
2505
+ * collected as stale, and every file of it as unapproved — the opposite of
2506
+ * what happened.
2507
+ *
2508
+ * ## Why the commit is VERIFIED rather than taken
2509
+ *
2510
+ * `merged_sha` is not reliably a commit this request created. When the target
2511
+ * already contains the source there is nothing to merge, so
2512
+ * `mergeChangeRequest` writes no commit and reports the TARGET TIP as the
2513
+ * merged state — and in a deployment that lands everything through change
2514
+ * requests, that tip is usually ANOTHER request's merge commit. Reading "the
2515
+ * recorded commit's own change" then answers with somebody else's files under
2516
+ * this request's number: the request looks like it changed files it never
2517
+ * touched, and becomes visible to whoever may read THOSE (cubic P1 on #347).
2518
+ *
2519
+ * So three things must hold, and all three are cheap:
2520
+ *
2521
+ * 1. the commit is in this clone,
2522
+ * 2. it has a SECOND parent — `mergeChangeRequest` merges `--no-ff`, so
2523
+ * every merge commit it writes has one, and a commit that has none was
2524
+ * not written by a merge,
2525
+ * 3. its subject names THIS request — see `merge-commit.ts`.
2526
+ *
2527
+ * Anything else fails closed: no files, so author-only, rather than a file
2528
+ * list that belongs to another change. The write side no longer records a
2529
+ * no-op merge's sha at all, so for rows written from now on all three hold by
2530
+ * construction; the verification is what protects the rows written before it.
2531
+ */
2532
+ async appliedChangeShas(workspaceId, applied) {
2533
+ const cwd = await this.repoDir(workspaceId);
2534
+ return this.mutex.run(workspaceId, async () => this.appliedEnds(cwd, applied));
2535
+ }
2536
+ /**
2537
+ * The changed-file list an APPLIED change request landed, read from its merge
2538
+ * commit against that commit's first parent, and shaped the way
2539
+ * `changedFilesForPr` shapes a branch pair — how an applied request is read
2540
+ * back, since its source branch is retired and the merge commit is what is
2541
+ * left of it.
2542
+ *
2543
+ * TWO dots, from the first parent. A three-dot diff would be read from the
2544
+ * merge base of the merge commit's two parents, i.e. it would re-report the
2545
+ * source branch's whole history instead of what landed on the target.
2546
+ *
2547
+ * Verified, not assumed — see {@link appliedChangeShas}. No fetch: the commit
2548
+ * is in this clone or this rejects.
2549
+ */
2550
+ async changedFilesOfAppliedChange(workspaceId, applied, opts = {}) {
2551
+ const cwd = await this.repoDir(workspaceId);
2552
+ return this.mutex.run(workspaceId, async () => {
2553
+ const { baseSha } = await this.appliedEnds(cwd, applied);
2554
+ return this.prFilesForRange(cwd, `${baseSha}..${applied.mergeSha}`, opts.patchCap ?? 400);
2555
+ });
2556
+ }
2557
+ /**
2558
+ * The same applied change as the two path views a change-request SUMMARY needs
2559
+ * — the flat touched-path list and the rename-aware pairs — out of one
2560
+ * `git diff`, exactly as {@link changedPathsAndPairsForPr} does for a branch
2561
+ * pair. Same verification and same no-fetch contract.
2562
+ */
2563
+ async changedPathsAndPairsOfAppliedChange(workspaceId, applied) {
2564
+ const cwd = await this.repoDir(workspaceId);
2565
+ return this.mutex.run(workspaceId, async () => {
2566
+ const { baseSha } = await this.appliedEnds(cwd, applied);
2567
+ const entries = await this.prChangedEntriesForRange(cwd, `${baseSha}..${applied.mergeSha}`);
2568
+ return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
2283
2569
  });
2284
2570
  }
2285
2571
  /**
@@ -2335,28 +2621,54 @@ export class GitService {
2335
2621
  * deadlock — and where the refs are the ones the merge is actually built on.
2336
2622
  */
2337
2623
  async prChangedPathsAt(cwd, baseRef, headRef, opts = {}) {
2338
- const { stdout } = await this.git(cwd, [
2339
- 'diff', '-M', '-z', '--name-status', `${baseRef}...${headRef}`,
2340
- ]);
2341
- // Deduped: both sides of a rename can collide with another entry's path.
2342
- return [
2343
- ...new Set(parseNameStatusZ(stdout)
2344
- // A rename onto or off the placeholder is a real file removed or
2345
- // added (see `withoutPlaceholderRename`): both of its paths are
2346
- // touched, or filtering the placeholder would lose the file.
2347
- //
2348
- // Authorizing the change needs both sides of EVERY rename, not just
2349
- // the placeholder's: git reports a rename under its new name alone,
2350
- // and the old name is a path the change deletes.
2351
- .flatMap((s) => s.previousPath &&
2352
- (opts.forAccessCheck || isFolderPlaceholder(s.path) || isFolderPlaceholder(s.previousPath))
2353
- ? [s.previousPath, s.path]
2354
- : [s.path])
2355
- // Same rule as `changedFilesForPr`: a roles.yaml change never
2356
- // survives a merge, so it is not a touched path for routing or
2357
- // summaries either.
2358
- .filter((p) => opts.forAccessCheck || p !== 'roles.yaml')),
2359
- ];
2624
+ return flattenChangedPaths(await this.prChangedEntriesAt(cwd, baseRef, headRef), opts);
2625
+ }
2626
+ /**
2627
+ * The same diff, left as parsed ENTRIES: each changed file with the path it
2628
+ * came from when git detected a rename.
2629
+ *
2630
+ * The flat path list cannot express that pairing, and a caller deciding what
2631
+ * someone may READ needs it. A file renamed out of a folder the caller cannot
2632
+ * open is readable under neither of its names — the diff of a rename shows
2633
+ * the old side's content — and a flat list that offers both paths unlabelled
2634
+ * cannot say which new file the closed old path belongs to. `forAccessCheck`
2635
+ * solves the write side of this by taking the union; a read gate needs the
2636
+ * pairing itself.
2637
+ */
2638
+ async prChangedEntriesAt(cwd, baseRef, headRef) {
2639
+ return this.prChangedEntriesForRange(cwd, `${baseRef}...${headRef}`);
2640
+ }
2641
+ /**
2642
+ * The same parsed entries over an already-decided `range`, so a merge
2643
+ * commit's own diff ({@link firstParentRange}) is read by the same parser as a
2644
+ * change request's branch pair.
2645
+ */
2646
+ async prChangedEntriesForRange(cwd, range) {
2647
+ const { stdout } = await this.git(cwd, ['diff', '-M', '-z', '--name-status', range]);
2648
+ return parseNameStatusZ(stdout);
2649
+ }
2650
+ /**
2651
+ * One diff, both views of it: the flat touched-path list every existing
2652
+ * caller reads, and the rename-aware pairs a read gate needs. The change-
2653
+ * request summary builder wants both, and must not pay for two `git diff`s
2654
+ * to get them — it is on the list path that is polled every 60s.
2655
+ */
2656
+ async changedPathsAndPairsForPr(workspaceId, baseBranch, headBranch, opts = {}) {
2657
+ assertValidBranchName(baseBranch);
2658
+ assertValidBranchName(headBranch);
2659
+ const cwd = await this.repoDir(workspaceId);
2660
+ // Same ref pinning and the same fetch skip as `changedPathsForPr`, for the
2661
+ // same reasons spelled out there.
2662
+ const pinned = opts.fetch === false ? await this.publishedPrCommits(cwd, baseBranch, headBranch) : null;
2663
+ if (!pinned) {
2664
+ await this.fetchPrRefs(cwd, baseBranch, headBranch);
2665
+ }
2666
+ return this.mutex.run(workspaceId, async () => {
2667
+ const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
2668
+ const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
2669
+ const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
2670
+ return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
2671
+ });
2360
2672
  }
2361
2673
  /**
2362
2674
  * The merge-base commit of a change request's two branches (their origin
@@ -3135,6 +3447,59 @@ function mapGitStatus(letter) {
3135
3447
  * Parse `git diff -M -z --name-status` output. NUL-separated tokens: each record
3136
3448
  * is `<statusLetter>\0<path>`, or for a rename/copy `<Rxxx|Cxxx>\0<old>\0<new>`.
3137
3449
  */
3450
+ /**
3451
+ * The flat touched-path list of a parsed `--name-status` diff. Lifted out of
3452
+ * `prChangedPathsAt` unchanged, so the pair-returning reader can share one
3453
+ * diff with it rather than running a second.
3454
+ */
3455
+ function flattenChangedPaths(entries, opts = {}) {
3456
+ // Deduped: both sides of a rename can collide with another entry's path.
3457
+ return [
3458
+ ...new Set(entries
3459
+ // A rename onto or off the placeholder is a real file removed or
3460
+ // added (see `withoutPlaceholderRename`): both of its paths are
3461
+ // touched, or filtering the placeholder would lose the file.
3462
+ //
3463
+ // Authorizing the change needs both sides of EVERY rename, not just
3464
+ // the placeholder's: git reports a rename under its new name alone,
3465
+ // and the old name is a path the change deletes.
3466
+ .flatMap((s) => s.previousPath &&
3467
+ (opts.forAccessCheck || isFolderPlaceholder(s.path) || isFolderPlaceholder(s.previousPath))
3468
+ ? [s.previousPath, s.path]
3469
+ : [s.path])
3470
+ // Same rule as `changedFilesForPr`: a roles.yaml change never
3471
+ // survives a merge, so it is not a touched path for routing or
3472
+ // summaries either.
3473
+ .filter((p) => opts.forAccessCheck || p !== 'roles.yaml')),
3474
+ ];
3475
+ }
3476
+ /**
3477
+ * The changed files of a diff as read-gate pairs: every file the request
3478
+ * proposes, carrying the path it was renamed from when it has one.
3479
+ *
3480
+ * Normalised and filtered EXACTLY as `changedFilesForPr` does it, through the
3481
+ * same `withoutPlaceholderRename` and the same two drops — because this is the
3482
+ * file set a change-request LIST decides visibility over and that one is the
3483
+ * file set its DETAIL decides over, and the two answering differently is how a
3484
+ * list comes to advertise a request its own detail refuses (or to hide one the
3485
+ * detail serves). Any future change to one filter belongs in both.
3486
+ */
3487
+ function changedPathPairs(entries) {
3488
+ const byPath = new Map();
3489
+ for (const raw of entries) {
3490
+ // A rename between a real file and the placeholder is a removal or an
3491
+ // addition of the real side; after this, the placeholder filter below
3492
+ // cannot take the file with it.
3493
+ const entry = withoutPlaceholderRename(raw);
3494
+ if (isFolderPlaceholder(entry.path) || entry.path === 'roles.yaml')
3495
+ continue;
3496
+ byPath.set(entry.path, {
3497
+ path: entry.path,
3498
+ ...(entry.previousPath ? { previousPath: entry.previousPath } : {}),
3499
+ });
3500
+ }
3501
+ return [...byPath.values()];
3502
+ }
3138
3503
  export function parseNameStatusZ(out) {
3139
3504
  const tokens = out.split('\0');
3140
3505
  const entries = [];