@bevel-software/platform-core-backend 0.4.1 → 0.5.1

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 (305) hide show
  1. package/THIRD-PARTY-NOTICES.md +6118 -0
  2. package/dist/core/core-ports.d.ts +15 -0
  3. package/dist/core/core-ports.d.ts.map +1 -1
  4. package/dist/core/core-ports.js.map +1 -1
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +40 -6
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +17 -1
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +153 -29
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core-config.d.ts +70 -21
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +134 -58
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +6 -0
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +74 -5
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +116 -15
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access-declarations.d.ts +93 -0
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  24. package/dist/modules/access/access-declarations.js +168 -0
  25. package/dist/modules/access/access-declarations.js.map +1 -0
  26. package/dist/modules/access/access-mutation.service.js +4 -4
  27. package/dist/modules/access/access-mutation.service.js.map +1 -1
  28. package/dist/modules/access/access-splice.d.ts +16 -1
  29. package/dist/modules/access/access-splice.d.ts.map +1 -1
  30. package/dist/modules/access/access-splice.js +40 -21
  31. package/dist/modules/access/access-splice.js.map +1 -1
  32. package/dist/modules/access/access.routes.d.ts.map +1 -1
  33. package/dist/modules/access/access.routes.js +51 -0
  34. package/dist/modules/access/access.routes.js.map +1 -1
  35. package/dist/modules/access/creator-access.d.ts +8 -0
  36. package/dist/modules/access/creator-access.d.ts.map +1 -1
  37. package/dist/modules/access/creator-access.js +21 -2
  38. package/dist/modules/access/creator-access.js.map +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  40. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  41. package/dist/modules/admin/admin-access.service.js +3 -2
  42. package/dist/modules/admin/admin-access.service.js.map +1 -1
  43. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  44. package/dist/modules/auth/auth.routes.js +50 -1
  45. package/dist/modules/auth/auth.routes.js.map +1 -1
  46. package/dist/modules/auth/auth.service.d.ts +39 -10
  47. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  48. package/dist/modules/auth/auth.service.js +38 -16
  49. package/dist/modules/auth/auth.service.js.map +1 -1
  50. package/dist/modules/database/core-schema.d.ts +124 -0
  51. package/dist/modules/database/core-schema.d.ts.map +1 -1
  52. package/dist/modules/database/core-schema.js +45 -0
  53. package/dist/modules/database/core-schema.js.map +1 -1
  54. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  55. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  56. package/dist/modules/groups/group-provision.service.js +288 -0
  57. package/dist/modules/groups/group-provision.service.js.map +1 -0
  58. package/dist/modules/groups/groups.contract.d.ts +106 -0
  59. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  60. package/dist/modules/groups/groups.contract.js +36 -0
  61. package/dist/modules/groups/groups.contract.js.map +1 -0
  62. package/dist/modules/groups/groups.routes.d.ts +42 -0
  63. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  64. package/dist/modules/groups/groups.routes.js +379 -0
  65. package/dist/modules/groups/groups.routes.js.map +1 -0
  66. package/dist/modules/groups/groups.service.d.ts +60 -0
  67. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  68. package/dist/modules/groups/groups.service.js +172 -0
  69. package/dist/modules/groups/groups.service.js.map +1 -0
  70. package/dist/modules/groups/index.d.ts +7 -0
  71. package/dist/modules/groups/index.d.ts.map +1 -0
  72. package/dist/modules/groups/index.js +6 -0
  73. package/dist/modules/groups/index.js.map +1 -0
  74. package/dist/modules/groups/join-proposals.d.ts +53 -0
  75. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  76. package/dist/modules/groups/join-proposals.js +67 -0
  77. package/dist/modules/groups/join-proposals.js.map +1 -0
  78. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  79. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  80. package/dist/modules/groups/join-requests.service.js +135 -0
  81. package/dist/modules/groups/join-requests.service.js.map +1 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  83. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  85. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  86. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  87. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  88. package/dist/modules/settings/deployment-settings.service.js +368 -0
  89. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  90. package/dist/modules/settings/setup.routes.d.ts +49 -0
  91. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  92. package/dist/modules/settings/setup.routes.js +292 -0
  93. package/dist/modules/settings/setup.routes.js.map +1 -0
  94. package/dist/modules/skills/index.d.ts +2 -1
  95. package/dist/modules/skills/index.d.ts.map +1 -1
  96. package/dist/modules/skills/index.js +1 -0
  97. package/dist/modules/skills/index.js.map +1 -1
  98. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  99. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  100. package/dist/modules/skills/pending-skills.service.js +147 -0
  101. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  102. package/dist/modules/skills/skills.contract.d.ts +32 -3
  103. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  104. package/dist/modules/skills/skills.contract.js +1 -1
  105. package/dist/modules/skills/skills.routes.d.ts +3 -2
  106. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  107. package/dist/modules/skills/skills.routes.js +18 -1
  108. package/dist/modules/skills/skills.routes.js.map +1 -1
  109. package/dist/modules/skills/skills.service.d.ts +22 -0
  110. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  111. package/dist/modules/skills/skills.service.js +24 -12
  112. package/dist/modules/skills/skills.service.js.map +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts +1 -1
  114. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  115. package/dist/modules/tool-manuals/index.js.map +1 -1
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  117. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  120. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  122. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  125. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  126. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  129. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  132. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  133. package/dist/modules/workflow/event-bus.d.ts +11 -0
  134. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  135. package/dist/modules/workflow/event-bus.js +22 -0
  136. package/dist/modules/workflow/event-bus.js.map +1 -1
  137. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  138. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  139. package/dist/modules/workflow/file-lock.service.js +16 -0
  140. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  142. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  143. package/dist/modules/workflow/git/branch-name.js +1 -1
  144. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  145. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  146. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  147. package/dist/modules/workflow/git/git.service.js +144 -52
  148. package/dist/modules/workflow/git/git.service.js.map +1 -1
  149. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  150. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  151. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  152. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  155. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  157. package/dist/modules/workflow/workflow.routes.js +95 -14
  158. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  159. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  160. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  161. package/dist/modules/workflow/workflow.service.js +381 -108
  162. package/dist/modules/workflow/workflow.service.js.map +1 -1
  163. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  164. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  165. package/dist/modules/workspace/bevel-ignore.js +5 -1
  166. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  167. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  168. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  169. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  170. package/dist/modules/workspace/kb-seed.service.js +228 -51
  171. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  173. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.routes.js +51 -18
  175. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  176. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  177. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.service.js +36 -9
  179. package/dist/modules/workspace/workspace.service.js.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.tools.js +8 -3
  182. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  183. package/dist/shared/kb-layout.test.js +2 -2
  184. package/dist/test-setup.d.ts +2 -0
  185. package/dist/test-setup.d.ts.map +1 -0
  186. package/dist/test-setup.js +18 -0
  187. package/dist/test-setup.js.map +1 -0
  188. package/kb-template/.bevelignore +6 -2
  189. package/kb-template/AGENTS.md +272 -0
  190. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  191. package/kb-template/access.md +36 -36
  192. package/migrations/0002_user_onboarding_done.sql +1 -0
  193. package/migrations/0003_deployment_settings.sql +9 -0
  194. package/migrations/meta/0001_snapshot.json +1486 -0
  195. package/migrations/meta/0002_snapshot.json +1493 -0
  196. package/migrations/meta/0003_snapshot.json +1552 -0
  197. package/migrations/meta/_journal.json +15 -1
  198. package/package.json +5 -4
  199. package/src/__tests__/branch-model.test.ts +79 -0
  200. package/src/__tests__/core-config.admin.test.ts +114 -0
  201. package/src/__tests__/database-url.test.ts +70 -0
  202. package/src/core/core-ports.ts +15 -0
  203. package/src/core/create-core-server.ts +55 -6
  204. package/src/core/create-core-services.ts +201 -36
  205. package/src/core-config.ts +139 -59
  206. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  207. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  208. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  209. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  210. package/src/modules/access/access-control.interface.ts +11 -0
  211. package/src/modules/access/access-control.service.ts +113 -15
  212. package/src/modules/access/access-declarations.ts +251 -0
  213. package/src/modules/access/access-mutation.service.ts +4 -4
  214. package/src/modules/access/access-splice.ts +64 -21
  215. package/src/modules/access/access.routes.ts +61 -0
  216. package/src/modules/access/creator-access.ts +22 -2
  217. package/src/modules/admin/admin-access.service.ts +4 -3
  218. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  219. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  220. package/src/modules/auth/auth.routes.ts +50 -1
  221. package/src/modules/auth/auth.service.ts +55 -17
  222. package/src/modules/database/core-schema.ts +46 -0
  223. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  224. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  225. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  226. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  227. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  228. package/src/modules/groups/group-provision.service.ts +328 -0
  229. package/src/modules/groups/groups.contract.ts +107 -0
  230. package/src/modules/groups/groups.routes.ts +433 -0
  231. package/src/modules/groups/groups.service.ts +193 -0
  232. package/src/modules/groups/index.ts +17 -0
  233. package/src/modules/groups/join-proposals.ts +114 -0
  234. package/src/modules/groups/join-requests.service.ts +185 -0
  235. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  236. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  237. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  238. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  239. package/src/modules/settings/deployment-settings.service.ts +419 -0
  240. package/src/modules/settings/setup.routes.ts +317 -0
  241. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  242. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  243. package/src/modules/skills/index.ts +3 -0
  244. package/src/modules/skills/pending-skills.service.ts +168 -0
  245. package/src/modules/skills/skills.contract.ts +34 -3
  246. package/src/modules/skills/skills.routes.ts +23 -2
  247. package/src/modules/skills/skills.service.ts +24 -16
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  251. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  252. package/src/modules/tool-manuals/index.ts +2 -0
  253. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  254. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  255. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  256. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  257. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  259. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  260. package/src/modules/workflow/event-bus.ts +25 -0
  261. package/src/modules/workflow/file-lock.service.ts +17 -0
  262. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  263. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  264. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  265. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  266. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  267. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  268. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  269. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  270. package/src/modules/workflow/git/branch-name.ts +5 -1
  271. package/src/modules/workflow/git/git.service.ts +159 -62
  272. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  273. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  274. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  275. package/src/modules/workflow/workflow.routes.ts +91 -13
  276. package/src/modules/workflow/workflow.service.ts +353 -13
  277. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  278. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  279. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  280. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  281. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  283. package/src/modules/workspace/bevel-ignore.ts +66 -62
  284. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  285. package/src/modules/workspace/kb-seed.service.ts +247 -53
  286. package/src/modules/workspace/workspace.routes.ts +54 -18
  287. package/src/modules/workspace/workspace.service.ts +45 -7
  288. package/src/modules/workspace/workspace.tools.ts +922 -917
  289. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  290. package/src/shared/__tests__/join-request.test.ts +96 -0
  291. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  292. package/src/shared/kb-layout.test.ts +2 -2
  293. package/src/test-setup.ts +18 -0
  294. package/kb-template/Agents/README.md +0 -38
  295. package/kb-template/CLAUDE.md +0 -419
  296. package/kb-template/Data/.gitkeep +0 -0
  297. package/kb-template/Data/README.md +0 -39
  298. package/kb-template/KnowledgeBase/README.md +0 -37
  299. package/kb-template/Pipelines/.gitkeep +0 -0
  300. package/kb-template/Pipelines/README.md +0 -38
  301. package/kb-template/Skills/.gitkeep +0 -0
  302. package/kb-template/Skills/README.md +0 -32
  303. package/kb-template/Tools/.gitkeep +0 -0
  304. package/kb-template/Tools/README.md +0 -42
  305. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -1,272 +1,275 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { execFile } from 'node:child_process';
3
- import { promisify } from 'node:util';
4
- import fs from 'node:fs/promises';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import type { AuthUser } from '@bevel-software/platform-shared';
8
- import { GitService } from '../git.service.js';
9
- import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
10
-
11
- /**
12
- * The post-merge refresh path: `WorkflowService.mergeChangeRequest` merges a
13
- * change request onto `origin/<base>` and then pulls the BASE branch's own
14
- * workspace so its working tree (what every file read serves) isn't left behind
15
- * the remote.
16
- *
17
- * The bug this covers: that pull ran against a clone whose git config had
18
- * drifted — a duplicated `remote.origin.fetch` refspec and/or a multi-valued
19
- * `branch.<base>.merge` — so git refused to refresh it with "Cannot rebase onto
20
- * multiple branches", and the merge was followed by a
21
- * `[merge] post-merge pull … failed` warning with the workspace stuck on the
22
- * pre-merge tree.
23
- */
24
-
25
- const execFileAsync = promisify(execFile);
26
- const BASE = 'current-company-state';
27
- const SOURCE = 'alice/add-feature';
28
- const KB_DIR = 'knowledge-base';
29
- const USER: AuthUser = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
30
-
31
- /** Clone `branch` into `<root>/<wsId>/knowledge-base`, like prod's layout. */
32
- async function cloneWorkspace(root: string, upstream: string, branch: string): Promise<string> {
33
- const wsDir = path.join(root, encodeURIComponent(branch));
34
- const repo = path.join(wsDir, KB_DIR);
35
- await fs.mkdir(wsDir, { recursive: true });
36
- await runGit(root, ['clone', '-b', branch, upstream, repo]);
37
- await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
38
- await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
39
- return repo;
40
- }
41
-
42
- /**
43
- * Bare upstream carrying BASE and a SOURCE branch with one new file, plus the
44
- * two workspaces prod would have: the source draft's (where the merge runs) and
45
- * the base branch's own (the one the post-merge pull refreshes).
46
- */
47
- async function seed(root: string) {
48
- const upstream = path.join(root, 'upstream.git');
49
- await runGit(root, ['init', '--bare', '-b', BASE, upstream]);
50
-
51
- const seedDir = path.join(root, '.seed');
52
- await fs.mkdir(seedDir);
53
- await runGit(seedDir, ['init', '-b', BASE]);
54
- await runGit(seedDir, ['remote', 'add', 'origin', upstream]);
55
- await fs.writeFile(path.join(seedDir, 'base.md'), 'base\n');
56
- await runGit(seedDir, ['add', '-A']);
57
- await runGit(seedDir, ['commit', '-m', 'base']);
58
- await runGit(seedDir, ['push', 'origin', BASE]);
59
- await runGit(seedDir, ['checkout', '-b', SOURCE]);
60
- await fs.writeFile(path.join(seedDir, 'feature.md'), 'from the change request\n');
61
- await runGit(seedDir, ['add', '-A']);
62
- await runGit(seedDir, ['commit', '-m', 'add feature']);
63
- await runGit(seedDir, ['push', 'origin', SOURCE]);
64
-
65
- const sourceRepo = await cloneWorkspace(root, upstream, SOURCE);
66
- const baseRepo = await cloneWorkspace(root, upstream, BASE);
67
- return { upstream, sourceRepo, baseRepo };
68
- }
69
-
70
- describe('post-merge refresh of the target branch workspace', () => {
71
- let root: string;
72
- const sourceWsId = encodeURIComponent(SOURCE);
73
- const baseWsId = encodeURIComponent(BASE);
74
-
75
- beforeEach(async () => {
76
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-post-merge-pull-'));
77
- });
78
-
79
- afterEach(async () => {
80
- await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch(() => {});
81
- });
82
-
83
- it('pulls the target workspace up to origin after a merge, even with a drifted clone config', async () => {
84
- const { sourceRepo, baseRepo } = await seed(root);
85
-
86
- // The target branch's workspace has drifted into the state that strands the
87
- // refresh: the branch tracks two merge refs and origin is fetched through
88
- // three refspecs.
89
- await runGit(baseRepo, ['config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`]);
90
- await runGit(baseRepo, ['config', '--add', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*']);
91
- await runGit(baseRepo, [
92
- 'config', '--add', 'remote.origin.fetch', `+refs/heads/${BASE}:refs/remotes/origin/${BASE}`,
93
- ]);
94
- // Guard: in this state git really does refuse to rebase — without the fix
95
- // this is exactly what the post-merge pull hit. `LC_ALL`/`LANG` pinned to
96
- // `C` for the same reason `GitService.git` pins them: the assertion below
97
- // matches git's English text, and a non-English runner would otherwise
98
- // report a false regression.
99
- await expect(
100
- execFileAsync('git', ['pull', '--rebase'], {
101
- cwd: baseRepo,
102
- env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
103
- }),
104
- ).rejects.toThrow(/multiple branches/i);
105
-
106
- const git = new GitService(
107
- stubWorkspaceService({
108
- [sourceWsId]: path.dirname(sourceRepo),
109
- [baseWsId]: path.dirname(baseRepo),
110
- }),
111
- stubWorkflowHooks(),
112
- KB_DIR,
113
- );
114
-
115
- // 1. The merge lands on origin/BASE (run from the caller's workspace).
116
- const merged = await git.mergeChangeRequest(
117
- sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
118
- );
119
- expect(merged.kind).toBe('merged');
120
-
121
- // 2. The post-merge refresh of the TARGET branch's own workspace succeeds.
122
- await expect(git.pull(baseWsId)).resolves.toBeUndefined();
123
-
124
- // The workspace is at origin's head, and serves the merged file.
125
- const originHead = await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`]);
126
- expect(await gitOut(baseRepo, ['rev-parse', 'HEAD'])).toBe(originHead);
127
- if (merged.kind === 'merged') expect(originHead).toBe(merged.sha);
128
- const feature = await fs.readFile(path.join(baseRepo, 'feature.md'), 'utf8');
129
- expect(feature.replace(/\r\n/g, '\n')).toBe('from the change request\n');
130
-
131
- // The clone was repaired rather than merely worked around, so the next
132
- // refresh starts from a healthy config.
133
- const mergeRefs = (await gitOut(baseRepo, ['config', '--get-all', `branch.${BASE}.merge`]))
134
- .split('\n').filter(Boolean);
135
- expect(mergeRefs).toEqual([`refs/heads/${BASE}`]);
136
- const refspecs = (await gitOut(baseRepo, ['config', '--get-all', 'remote.origin.fetch']))
137
- .split('\n').filter(Boolean);
138
- expect(refspecs).toEqual(['+refs/heads/*:refs/remotes/origin/*']);
139
- });
140
-
141
- // The reported failure, reproduced. `git pull` reads its rebase target from
142
- // `.git/FETCH_HEAD`, and the app fetches into the same clone from paths that
143
- // don't share the workspace mutex (`GitService.fetchOriginIfStale`,
144
- // `WorkspaceService.ensureRemotesFetched` — both driven by the polling the UI
145
- // does around a merge). On a clone whose `branch.<base>.merge` has drifted to
146
- // two values, one of those bare fetches writes TWO for-merge FETCH_HEAD
147
- // entries; landing inside the pull's fetch→read window makes it die with
148
- // "Cannot rebase onto multiple branches".
149
- //
150
- // Reproduced deterministically below: the drift is applied, ONE real bare
151
- // fetch is run to capture the exact FETCH_HEAD bytes it produces (asserted to
152
- // carry two for-merge entries — the loaded gun), and a second process then
153
- // replays those real bytes in a tight loop while the refresh runs. Replaying
154
- // is the concurrent fetch's last step; looping it removes the timing luck
155
- // that a `git fetch` loop leaves to chance. Against the pre-fix
156
- // `git pull --rebase --autostash origin <base>` this dies with the reported
157
- // fatal; the refresh now touches no shared state, so it cannot.
158
- it('refreshes the target workspace while a concurrent fetch rewrites FETCH_HEAD', async () => {
159
- const { sourceRepo, baseRepo } = await seed(root);
160
- const git = new GitService(
161
- stubWorkspaceService({
162
- [sourceWsId]: path.dirname(sourceRepo),
163
- [baseWsId]: path.dirname(baseRepo),
164
- }),
165
- stubWorkflowHooks(),
166
- KB_DIR,
167
- );
168
-
169
- const merged = await git.mergeChangeRequest(
170
- sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
171
- );
172
- expect(merged.kind).toBe('merged');
173
-
174
- // The drift, then one real bare fetch — exactly what
175
- // `fetchOriginIfStale` / `ensureRemotesFetched` run outside the mutex.
176
- await runGit(baseRepo, ['config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`]);
177
- await runGit(baseRepo, ['fetch', '--prune', 'origin']);
178
- const fetchHeadPath = path.join(baseRepo, '.git', 'FETCH_HEAD');
179
- const concurrentFetchWrote = await fs.readFile(fetchHeadPath, 'utf8');
180
- const forMerge = concurrentFetchWrote
181
- .split('\n').filter((l) => l && !l.includes('not-for-merge'));
182
- expect(forMerge.length).toBe(2);
183
-
184
- // Now run that same bare fetch in a loop alongside the refresh. It re-adds
185
- // the drift each round the way whatever added it in production kept doing
186
- // — the refresh's own self-heal would otherwise disarm the reproduction
187
- // after round one. A real `git fetch` (rather than a tight write loop) also
188
- // keeps the competitor self-throttling instead of starving the box.
189
- //
190
- // Driven from Node rather than `sh -c` so the suite stays runnable on
191
- // Windows: each round still spawns real, genuinely concurrent git
192
- // processes — `git.pull` is awaiting its own child while these run.
193
- let stop = false;
194
- const noise = (async () => {
195
- while (!stop) {
196
- await runGit(baseRepo, [
197
- 'config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`,
198
- ]).catch(() => undefined);
199
- await runGit(baseRepo, ['fetch', '--prune', 'origin']).catch(() => undefined);
200
- }
201
- })();
202
- try {
203
- // ~40% of rounds hit the fatal pre-fix (measured), so 20 rounds makes a
204
- // pre-fix regression a practical certainty while staying quick.
205
- for (let round = 0; round < 20; round += 1) {
206
- await expect(git.pull(baseWsId)).resolves.toBeUndefined();
207
- }
208
- } finally {
209
- stop = true;
210
- await noise;
211
- }
212
-
213
- // Still exactly where the merge left origin, with the merged file served.
214
- expect(await gitOut(baseRepo, ['rev-parse', 'HEAD']))
215
- .toBe(await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`]));
216
- const feature = await fs.readFile(path.join(baseRepo, 'feature.md'), 'utf8');
217
- expect(feature.replace(/\r\n/g, '\n')).toBe('from the change request\n');
218
- }, 60_000);
219
-
220
- // The same config drift, one step earlier in the flow. `git fetch origin
221
- // <branch>` updates `refs/remotes/origin/<branch>` only *opportunistically* —
222
- // git consults `remote.origin.fetch` to decide — so a refspec narrowed to
223
- // some other branch leaves `origin/<source>` frozen at whatever the clone
224
- // last saw. The merge then lands a stale source tip on the base and the CR's
225
- // latest revision silently goes missing (and every push attempt burns against
226
- // the same stale ref). Naming the destination refspecs explicitly is what
227
- // makes the fetch independent of that config.
228
- it('merges the latest source tip even when remote.origin.fetch no longer covers it', async () => {
229
- const { upstream, sourceRepo, baseRepo } = await seed(root);
230
-
231
- // Drift: the clone the merge runs in now fetches ONLY the base branch, so
232
- // nothing refreshes `origin/<SOURCE>` through ambient config any more.
233
- await runGit(sourceRepo, [
234
- 'config', '--replace-all', 'remote.origin.fetch',
235
- `+refs/heads/${BASE}:refs/remotes/origin/${BASE}`,
236
- ]);
237
-
238
- // The change request gets another revision pushed after that clone's last
239
- // sight of it — the commit the merge must not miss.
240
- const author = path.join(root, '.author');
241
- await runGit(root, ['clone', '-b', SOURCE, upstream, author]);
242
- await fs.writeFile(path.join(author, 'revision.md'), 'second revision\n');
243
- await runGit(author, ['add', '-A']);
244
- await runGit(author, ['commit', '-m', 'address review feedback']);
245
- await runGit(author, ['push', 'origin', SOURCE]);
246
- const latestSource = await gitOut(author, ['rev-parse', 'HEAD']);
247
-
248
- const git = new GitService(
249
- stubWorkspaceService({
250
- [sourceWsId]: path.dirname(sourceRepo),
251
- [baseWsId]: path.dirname(baseRepo),
252
- }),
253
- stubWorkflowHooks(),
254
- KB_DIR,
255
- );
256
-
257
- const merged = await git.mergeChangeRequest(
258
- sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
259
- );
260
- expect(merged.kind).toBe('merged');
261
-
262
- // Origin's base carries the LATEST source commit, not the tip the drifted
263
- // refspec would have left behind.
264
- const upstreamLog = await gitOut(upstream, ['log', '--format=%H', BASE]);
265
- expect(upstreamLog.split('\n')).toContain(latestSource);
266
-
267
- // ...and the post-merge refresh of the base workspace serves that revision.
268
- await expect(git.pull(baseWsId)).resolves.toBeUndefined();
269
- const revision = await fs.readFile(path.join(baseRepo, 'revision.md'), 'utf8');
270
- expect(revision.replace(/\r\n/g, '\n')).toBe('second revision\n');
271
- });
272
- });
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import type { AuthUser } from '@bevel-software/platform-shared';
8
+ import { GitService } from '../git.service.js';
9
+ import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
10
+
11
+ /**
12
+ * The post-merge refresh path: `WorkflowService.mergeChangeRequest` merges a
13
+ * change request onto `origin/<base>` and then pulls the BASE branch's own
14
+ * workspace so its working tree (what every file read serves) isn't left behind
15
+ * the remote.
16
+ *
17
+ * The bug this covers: that pull ran against a clone whose git config had
18
+ * drifted — a duplicated `remote.origin.fetch` refspec and/or a multi-valued
19
+ * `branch.<base>.merge` — so git refused to refresh it with "Cannot rebase onto
20
+ * multiple branches", and the merge was followed by a
21
+ * `[merge] post-merge pull … failed` warning with the workspace stuck on the
22
+ * pre-merge tree.
23
+ */
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const BASE = 'current-company-state';
27
+ const SOURCE = 'alice/add-feature';
28
+ const KB_DIR = 'knowledge-base';
29
+ const USER: AuthUser = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
30
+
31
+ /** Clone `branch` into `<root>/<wsId>/knowledge-base`, like prod's layout. */
32
+ async function cloneWorkspace(root: string, upstream: string, branch: string): Promise<string> {
33
+ const wsDir = path.join(root, encodeURIComponent(branch));
34
+ const repo = path.join(wsDir, KB_DIR);
35
+ await fs.mkdir(wsDir, { recursive: true });
36
+ await runGit(root, ['clone', '-b', branch, upstream, repo]);
37
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
38
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
39
+ return repo;
40
+ }
41
+
42
+ /**
43
+ * Bare upstream carrying BASE and a SOURCE branch with one new file, plus the
44
+ * two workspaces prod would have: the source draft's (where the merge runs) and
45
+ * the base branch's own (the one the post-merge pull refreshes).
46
+ */
47
+ async function seed(root: string) {
48
+ const upstream = path.join(root, 'upstream.git');
49
+ await runGit(root, ['init', '--bare', '-b', BASE, upstream]);
50
+
51
+ const seedDir = path.join(root, '.seed');
52
+ await fs.mkdir(seedDir);
53
+ await runGit(seedDir, ['init', '-b', BASE]);
54
+ await runGit(seedDir, ['remote', 'add', 'origin', upstream]);
55
+ await fs.writeFile(path.join(seedDir, 'base.md'), 'base\n');
56
+ await runGit(seedDir, ['add', '-A']);
57
+ await runGit(seedDir, ['commit', '-m', 'base']);
58
+ await runGit(seedDir, ['push', 'origin', BASE]);
59
+ await runGit(seedDir, ['checkout', '-b', SOURCE]);
60
+ await fs.writeFile(path.join(seedDir, 'feature.md'), 'from the change request\n');
61
+ await runGit(seedDir, ['add', '-A']);
62
+ await runGit(seedDir, ['commit', '-m', 'add feature']);
63
+ await runGit(seedDir, ['push', 'origin', SOURCE]);
64
+
65
+ const sourceRepo = await cloneWorkspace(root, upstream, SOURCE);
66
+ const baseRepo = await cloneWorkspace(root, upstream, BASE);
67
+ return { upstream, sourceRepo, baseRepo };
68
+ }
69
+
70
+ describe('post-merge refresh of the target branch workspace', () => {
71
+ let root: string;
72
+ const sourceWsId = encodeURIComponent(SOURCE);
73
+ const baseWsId = encodeURIComponent(BASE);
74
+
75
+ beforeEach(async () => {
76
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-post-merge-pull-'));
77
+ });
78
+
79
+ afterEach(async () => {
80
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch(() => {});
81
+ });
82
+
83
+ it('pulls the target workspace up to origin after a merge, even with a drifted clone config', async () => {
84
+ const { sourceRepo, baseRepo } = await seed(root);
85
+
86
+ // The target branch's workspace has drifted into the state that strands the
87
+ // refresh: the branch tracks two merge refs and origin is fetched through
88
+ // three refspecs.
89
+ await runGit(baseRepo, ['config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`]);
90
+ await runGit(baseRepo, ['config', '--add', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*']);
91
+ await runGit(baseRepo, [
92
+ 'config', '--add', 'remote.origin.fetch', `+refs/heads/${BASE}:refs/remotes/origin/${BASE}`,
93
+ ]);
94
+ // Guard: in this state git really does refuse to rebase — without the fix
95
+ // this is exactly what the post-merge pull hit. `LC_ALL`/`LANG` pinned to
96
+ // `C` for the same reason `GitService.git` pins them: the assertion below
97
+ // matches git's English text, and a non-English runner would otherwise
98
+ // report a false regression.
99
+ await expect(
100
+ execFileAsync('git', ['pull', '--rebase'], {
101
+ cwd: baseRepo,
102
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
103
+ }),
104
+ ).rejects.toThrow(/multiple branches/i);
105
+
106
+ const git = new GitService(
107
+ stubWorkspaceService({
108
+ [sourceWsId]: path.dirname(sourceRepo),
109
+ [baseWsId]: path.dirname(baseRepo),
110
+ }),
111
+ stubWorkflowHooks(),
112
+ KB_DIR,
113
+ );
114
+
115
+ // 1. The merge lands on origin/BASE (run from the caller's workspace).
116
+ const merged = await git.mergeChangeRequest(
117
+ sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
118
+ );
119
+ expect(merged.kind).toBe('merged');
120
+
121
+ // 2. The post-merge refresh of the TARGET branch's own workspace succeeds.
122
+ await expect(git.pull(baseWsId)).resolves.toBeUndefined();
123
+
124
+ // The workspace is at origin's head, and serves the merged file.
125
+ const originHead = await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`]);
126
+ expect(await gitOut(baseRepo, ['rev-parse', 'HEAD'])).toBe(originHead);
127
+ if (merged.kind === 'merged') expect(originHead).toBe(merged.sha);
128
+ const feature = await fs.readFile(path.join(baseRepo, 'feature.md'), 'utf8');
129
+ expect(feature.replace(/\r\n/g, '\n')).toBe('from the change request\n');
130
+
131
+ // The clone was repaired rather than merely worked around, so the next
132
+ // refresh starts from a healthy config.
133
+ const mergeRefs = (await gitOut(baseRepo, ['config', '--get-all', `branch.${BASE}.merge`]))
134
+ .split('\n').filter(Boolean);
135
+ expect(mergeRefs).toEqual([`refs/heads/${BASE}`]);
136
+ const refspecs = (await gitOut(baseRepo, ['config', '--get-all', 'remote.origin.fetch']))
137
+ .split('\n').filter(Boolean);
138
+ expect(refspecs).toEqual(['+refs/heads/*:refs/remotes/origin/*']);
139
+ });
140
+
141
+ // The reported failure, reproduced. `git pull` reads its rebase target from
142
+ // `.git/FETCH_HEAD`, and the app fetches into the same clone from paths that
143
+ // don't share the workspace mutex (`GitService.fetchOriginIfStale`,
144
+ // `WorkspaceService.ensureRemotesFetched` — both driven by the polling the UI
145
+ // does around a merge). On a clone whose `branch.<base>.merge` has drifted to
146
+ // two values, one of those bare fetches writes TWO for-merge FETCH_HEAD
147
+ // entries; landing inside the pull's fetch→read window makes it die with
148
+ // "Cannot rebase onto multiple branches".
149
+ //
150
+ // Reproduced deterministically below: the drift is applied, ONE real bare
151
+ // fetch is run to capture the exact FETCH_HEAD bytes it produces (asserted to
152
+ // carry two for-merge entries — the loaded gun), and a second process then
153
+ // replays those real bytes in a tight loop while the refresh runs. Replaying
154
+ // is the concurrent fetch's last step; looping it removes the timing luck
155
+ // that a `git fetch` loop leaves to chance. Against the pre-fix
156
+ // `git pull --rebase --autostash origin <base>` this dies with the reported
157
+ // fatal; the refresh now touches no shared state, so it cannot.
158
+ it('refreshes the target workspace while a concurrent fetch rewrites FETCH_HEAD', async () => {
159
+ const { sourceRepo, baseRepo } = await seed(root);
160
+ const git = new GitService(
161
+ stubWorkspaceService({
162
+ [sourceWsId]: path.dirname(sourceRepo),
163
+ [baseWsId]: path.dirname(baseRepo),
164
+ }),
165
+ stubWorkflowHooks(),
166
+ KB_DIR,
167
+ );
168
+
169
+ const merged = await git.mergeChangeRequest(
170
+ sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
171
+ );
172
+ expect(merged.kind).toBe('merged');
173
+
174
+ // The drift, then one real bare fetch — exactly what
175
+ // `fetchOriginIfStale` / `ensureRemotesFetched` run outside the mutex.
176
+ await runGit(baseRepo, ['config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`]);
177
+ await runGit(baseRepo, ['fetch', '--prune', 'origin']);
178
+ const fetchHeadPath = path.join(baseRepo, '.git', 'FETCH_HEAD');
179
+ const concurrentFetchWrote = await fs.readFile(fetchHeadPath, 'utf8');
180
+ const forMerge = concurrentFetchWrote
181
+ .split('\n').filter((l) => l && !l.includes('not-for-merge'));
182
+ expect(forMerge.length).toBe(2);
183
+
184
+ // Now run that same bare fetch in a loop alongside the refresh. It re-adds
185
+ // the drift each round the way whatever added it in production kept doing
186
+ // — the refresh's own self-heal would otherwise disarm the reproduction
187
+ // after round one. A real `git fetch` (rather than a tight write loop) also
188
+ // keeps the competitor self-throttling instead of starving the box.
189
+ //
190
+ // Driven from Node rather than `sh -c` so the suite stays runnable on
191
+ // Windows: each round still spawns real, genuinely concurrent git
192
+ // processes — `git.pull` is awaiting its own child while these run.
193
+ let stop = false;
194
+ const noise = (async () => {
195
+ while (!stop) {
196
+ await runGit(baseRepo, [
197
+ 'config', '--add', `branch.${BASE}.merge`, `refs/heads/${SOURCE}`,
198
+ ]).catch(() => undefined);
199
+ await runGit(baseRepo, ['fetch', '--prune', 'origin']).catch(() => undefined);
200
+ }
201
+ })();
202
+ try {
203
+ // ~40% of rounds hit the fatal pre-fix (measured), so 20 rounds makes a
204
+ // pre-fix regression a practical certainty while staying quick.
205
+ for (let round = 0; round < 20; round += 1) {
206
+ await expect(git.pull(baseWsId)).resolves.toBeUndefined();
207
+ }
208
+ } finally {
209
+ stop = true;
210
+ await noise;
211
+ }
212
+
213
+ // Still exactly where the merge left origin, with the merged file served.
214
+ expect(await gitOut(baseRepo, ['rev-parse', 'HEAD']))
215
+ .toBe(await gitOut(baseRepo, ['rev-parse', `origin/${BASE}`]));
216
+ const feature = await fs.readFile(path.join(baseRepo, 'feature.md'), 'utf8');
217
+ expect(feature.replace(/\r\n/g, '\n')).toBe('from the change request\n');
218
+ // 20 pull rounds racing a competitor fetch loop: seconds on Linux, but on
219
+ // Windows every round is a stack of process spawns contending with the
220
+ // rest of the suite, so the cap has to absorb full-parallel-run load.
221
+ }, 180_000);
222
+
223
+ // The same config drift, one step earlier in the flow. `git fetch origin
224
+ // <branch>` updates `refs/remotes/origin/<branch>` only *opportunistically* —
225
+ // git consults `remote.origin.fetch` to decide — so a refspec narrowed to
226
+ // some other branch leaves `origin/<source>` frozen at whatever the clone
227
+ // last saw. The merge then lands a stale source tip on the base and the CR's
228
+ // latest revision silently goes missing (and every push attempt burns against
229
+ // the same stale ref). Naming the destination refspecs explicitly is what
230
+ // makes the fetch independent of that config.
231
+ it('merges the latest source tip even when remote.origin.fetch no longer covers it', async () => {
232
+ const { upstream, sourceRepo, baseRepo } = await seed(root);
233
+
234
+ // Drift: the clone the merge runs in now fetches ONLY the base branch, so
235
+ // nothing refreshes `origin/<SOURCE>` through ambient config any more.
236
+ await runGit(sourceRepo, [
237
+ 'config', '--replace-all', 'remote.origin.fetch',
238
+ `+refs/heads/${BASE}:refs/remotes/origin/${BASE}`,
239
+ ]);
240
+
241
+ // The change request gets another revision pushed after that clone's last
242
+ // sight of it — the commit the merge must not miss.
243
+ const author = path.join(root, '.author');
244
+ await runGit(root, ['clone', '-b', SOURCE, upstream, author]);
245
+ await fs.writeFile(path.join(author, 'revision.md'), 'second revision\n');
246
+ await runGit(author, ['add', '-A']);
247
+ await runGit(author, ['commit', '-m', 'address review feedback']);
248
+ await runGit(author, ['push', 'origin', SOURCE]);
249
+ const latestSource = await gitOut(author, ['rev-parse', 'HEAD']);
250
+
251
+ const git = new GitService(
252
+ stubWorkspaceService({
253
+ [sourceWsId]: path.dirname(sourceRepo),
254
+ [baseWsId]: path.dirname(baseRepo),
255
+ }),
256
+ stubWorkflowHooks(),
257
+ KB_DIR,
258
+ );
259
+
260
+ const merged = await git.mergeChangeRequest(
261
+ sourceWsId, SOURCE, BASE, { subject: 'Add feature (#1)', body: 'Merged via Bevel' }, USER,
262
+ );
263
+ expect(merged.kind).toBe('merged');
264
+
265
+ // Origin's base carries the LATEST source commit, not the tip the drifted
266
+ // refspec would have left behind.
267
+ const upstreamLog = await gitOut(upstream, ['log', '--format=%H', BASE]);
268
+ expect(upstreamLog.split('\n')).toContain(latestSource);
269
+
270
+ // ...and the post-merge refresh of the base workspace serves that revision.
271
+ await expect(git.pull(baseWsId)).resolves.toBeUndefined();
272
+ const revision = await fs.readFile(path.join(baseRepo, 'revision.md'), 'utf8');
273
+ expect(revision.replace(/\r\n/g, '\n')).toBe('second revision\n');
274
+ });
275
+ });
@@ -34,6 +34,11 @@ async function seedWorkspace(root: string, workspaceId: string): Promise<{
34
34
  // identity must live in the repo, not just in this test's runGit env.
35
35
  await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
36
36
  await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
37
+ // autocrlf off: the autostash test reads back a file `git stash pop` wrote.
38
+ // Under Git-for-Windows' system-level `core.autocrlf=true` that checkout
39
+ // would rewrite the LF fixture as CRLF and fail the byte-for-byte assertion.
40
+ // A no-op on Linux/CI.
41
+ await runGit(repo, ['config', 'core.autocrlf', 'false']);
37
42
  return { upstream, repo };
38
43
  }
39
44