@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import type { AuthUser } from '@bevel-software/platform-shared';
9
+ import { NodeFs } from '../../../kb-fs/node-fs.js';
10
+ import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
11
+ import { WorkspaceService } from '../../../workspace/workspace.service.js';
12
+ import { KbRepository, repositoryDirFor } from '../../../workspace/kb-repository.js';
13
+ import { WorkflowHooks } from '../../workflow-hooks.js';
14
+ import { GitService } from '../git.service.js';
15
+ import { NodeGitRunner } from '../node-git-runner.js';
16
+ import { workspaceIdForBranch } from '../../../../shared/workspace-id.js';
17
+ import { newWorkspaceService } from '../../../../__tests__/workspace-service.js';
18
+
19
+ /**
20
+ * The git layer over a knowledge base of WORKTREES of one repository: the
21
+ * same answers as over clones of the same remote, two worktrees committing at
22
+ * once, and a branch deleted while its own worktree is open. Real git.
23
+ */
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const TARGET = 'current-company-state';
27
+ const ALICE: AuthUser = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' };
28
+
29
+ async function git(cwd: string, args: string[]): Promise<string> {
30
+ const { stdout } = await execFileAsync('git', args, {
31
+ cwd,
32
+ env: {
33
+ ...process.env,
34
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
35
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
36
+ },
37
+ });
38
+ return stdout.toString();
39
+ }
40
+
41
+ let root: string;
42
+ let upstream: string;
43
+ let workspacesRoot: string;
44
+ let workspaces: WorkspaceService;
45
+ let gitService: GitService;
46
+
47
+ beforeEach(async () => {
48
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-worktrees-'));
49
+ workspacesRoot = path.join(root, 'workspaces');
50
+ await fs.mkdir(workspacesRoot, { recursive: true });
51
+ upstream = path.join(root, 'upstream.git');
52
+ await git(root, ['init', '--bare', '-b', TARGET, upstream]);
53
+ const seed = path.join(root, '.seed');
54
+ await fs.mkdir(seed);
55
+ await git(seed, ['init', '-b', TARGET]);
56
+ await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
57
+ await git(seed, ['add', '-A']);
58
+ await git(seed, ['commit', '-m', 'init']);
59
+ for (const draft of ['alice/a', 'bob/b']) {
60
+ await git(seed, ['checkout', '-q', '-b', draft, TARGET]);
61
+ await fs.writeFile(path.join(seed, `${draft.split('/')[0]}.md`), `${draft}\n`);
62
+ await git(seed, ['add', '-A']);
63
+ await git(seed, ['commit', '-m', `on ${draft}`]);
64
+ }
65
+ await git(seed, ['push', '-q', upstream, TARGET, 'alice/a', 'bob/b']);
66
+
67
+ const mutex = new WorkspaceMutex();
68
+ const runner = new NodeGitRunner();
69
+ const repository = new KbRepository({
70
+ dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
71
+ kbRepoUrl: () => upstream,
72
+ gitRunner: runner,
73
+ });
74
+ workspaces = newWorkspaceService(
75
+ workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
76
+ );
77
+ gitService = new GitService(workspaces, new WorkflowHooks(), testKbContext(), mutex, null, runner);
78
+ });
79
+
80
+ afterEach(async () => {
81
+ await fs.rm(root, { recursive: true, force: true });
82
+ });
83
+
84
+ const id = (branch: string) => workspaceIdForBranch(branch);
85
+ const checkoutOf = (branch: string) => path.join(workspacesRoot, id(branch), 'knowledge-base');
86
+
87
+ describe('GitService over worktrees', () => {
88
+ it('lands two commits requested at the same time in two worktrees, and pushes both', async () => {
89
+ await workspaces.getOrCreateForBranch('alice/a');
90
+ await workspaces.getOrCreateForBranch('bob/b');
91
+ await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
92
+ await fs.writeFile(path.join(checkoutOf('bob/b'), 'bob.md'), 'bob edits\n');
93
+
94
+ const [a, b] = await Promise.all([
95
+ gitService.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits'),
96
+ gitService.commitFile(id('bob/b'), ALICE, 'knowledge-base/bob.md', 'bob edits'),
97
+ ]);
98
+ expect(a).not.toBeNull();
99
+ expect(b).not.toBeNull();
100
+ await Promise.all([gitService.push(id('alice/a'), ALICE), gitService.push(id('bob/b'), ALICE)]);
101
+
102
+ expect(await git(upstream, ['show', 'alice/a:alice.md'])).toBe('alice edits\n');
103
+ expect(await git(upstream, ['show', 'bob/b:bob.md'])).toBe('bob edits\n');
104
+ // Each push left its branch tracking its own remote branch.
105
+ expect((await git(checkoutOf('alice/a'), ['rev-parse', '--abbrev-ref', '@{upstream}'])).trim()).toBe('origin/alice/a');
106
+ expect((await git(checkoutOf('bob/b'), ['status', '--porcelain'])).trim()).toBe('');
107
+ });
108
+
109
+ it("answers a change request's files and merges it exactly as a clone of the same remote does", async () => {
110
+ await workspaces.getOrCreateForBranch(TARGET);
111
+ await workspaces.getOrCreateForBranch('alice/a');
112
+ const onWorktrees = await gitService.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
113
+
114
+ // The same question asked of a full clone of the same remote.
115
+ const cloneRoot = path.join(root, 'clones');
116
+ const clone = path.join(cloneRoot, id(TARGET), 'knowledge-base');
117
+ await fs.mkdir(path.dirname(clone), { recursive: true });
118
+ await git(root, ['clone', '-q', '-b', TARGET, upstream, clone]);
119
+ const clones = newWorkspaceService(cloneRoot, upstream, testKbContext(), new NodeFs());
120
+ const onClones = await new GitService(clones, new WorkflowHooks(), testKbContext()).changedFilesForPr(
121
+ id(TARGET), TARGET, 'alice/a',
122
+ );
123
+ expect(onWorktrees).toEqual(onClones);
124
+
125
+ await gitService.mergeChangeRequest(
126
+ id(TARGET), 'alice/a', TARGET, { subject: 'Merge alice/a (#1)', body: '' }, ALICE,
127
+ );
128
+ expect(await git(upstream, ['show', `${TARGET}:alice.md`])).toBe('alice/a\n');
129
+ });
130
+
131
+ it('deletes a branch whose own worktree is open, and removing that workspace takes the worktree and its ref', async () => {
132
+ await workspaces.getOrCreateForBranch(TARGET);
133
+ await workspaces.getOrCreateForBranch('alice/a');
134
+
135
+ await gitService.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
136
+ await workspaces.deleteWorkspace(id('alice/a'));
137
+
138
+ await expect(git(upstream, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
139
+ await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
140
+ await expect(fs.access(checkoutOf('alice/a'))).rejects.toThrow();
141
+ expect((await git(checkoutOf(TARGET), ['status', '--porcelain'])).trim()).toBe('');
142
+ });
143
+
144
+ it("finishes a branch's first open raced by git operations on that branch, and a fetch joined from inside one", async () => {
145
+ // The first request for a branch fans out: the page opens the workspace
146
+ // while the git layer asks for its status — inside the branch's
147
+ // reservation, resolving the same workspace — and a listing fetches.
148
+ // The worktree add waits for running git operations, and they wait for
149
+ // the open: nothing may wait for itself.
150
+ await workspaces.getOrCreateForBranch(TARGET);
151
+ const all = Promise.all([
152
+ workspaces.getOrCreateForBranch('bob/b'),
153
+ gitService.status(id('bob/b')),
154
+ gitService.listBranches(id(TARGET), { freshFetch: true }),
155
+ gitService.status(id('bob/b')),
156
+ ]);
157
+ let timeout: ReturnType<typeof setTimeout> | undefined;
158
+ try {
159
+ await Promise.race([
160
+ all,
161
+ new Promise((_, reject) => {
162
+ timeout = setTimeout(() => reject(new Error('deadlocked')), 20_000);
163
+ }),
164
+ ]);
165
+ } finally {
166
+ if (timeout) clearTimeout(timeout);
167
+ }
168
+ expect((await git(checkoutOf('bob/b'), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('bob/b');
169
+ });
170
+
171
+ it('lists branches from one fetch of the repository, strictly', async () => {
172
+ await workspaces.getOrCreateForBranch(TARGET);
173
+ const branches = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
174
+ expect(branches.map((b) => b.name).sort()).toEqual(['alice/a', 'bob/b', TARGET].sort());
175
+ expect(gitService.lastRemoteContact()?.ok).toBe(true);
176
+ });
177
+
178
+ it("does not list another worktree's local branch once origin no longer has it, so the orphan sweep can take it", async () => {
179
+ await workspaces.getOrCreateForBranch(TARGET);
180
+ await workspaces.getOrCreateForBranch('bob/b');
181
+ // Deleted on the host while its worktree stays open: the repository
182
+ // still holds bob/b's local ref, as bob's clone held it.
183
+ await git(upstream, ['branch', '-D', 'bob/b']);
184
+
185
+ const fromTarget = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
186
+ expect(fromTarget.map((b) => b.name).sort()).toEqual(['alice/a', TARGET].sort());
187
+ // A clone never saw another branch's local ref either: listed from its
188
+ // own worktree, a branch is its own.
189
+ const fromBob = await gitService.listBranches(id('bob/b'), { freshFetch: true });
190
+ expect(fromBob.find((b) => b.name === 'bob/b')?.hasRemote).toBe(false);
191
+
192
+ const { removed } = await workspaces.sweepOrphanedWorkspaces(fromTarget.map((b) => b.name));
193
+ expect(removed).toEqual([id('bob/b')]);
194
+ await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/bob/b'])).rejects.toThrow();
195
+ });
196
+ });
197
+
198
+ describe('GitService and the repository administrator', () => {
199
+ /** The repository, noting whether its administrator's section is held while git runs. */
200
+ class RecordingRepository extends KbRepository {
201
+ depth = 0;
202
+ override exclusive<T>(fn: () => Promise<T>): Promise<T> {
203
+ return super.exclusive(async () => {
204
+ this.depth += 1;
205
+ try { return await fn(); } finally { this.depth -= 1; }
206
+ });
207
+ }
208
+ }
209
+
210
+ it('writes the shared config as the administrator, and runs everything else as itself', async () => {
211
+ const mutex = new WorkspaceMutex();
212
+ const runner = new NodeGitRunner();
213
+ const repository = new RecordingRepository({
214
+ dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
215
+ kbRepoUrl: () => upstream,
216
+ gitRunner: runner,
217
+ });
218
+ const recorded = newWorkspaceService(
219
+ workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
220
+ );
221
+ const calls: { args: string[]; held: boolean }[] = [];
222
+ const recordingRunner = {
223
+ defaultTimeoutMs: runner.defaultTimeoutMs,
224
+ credentials: runner.credentials,
225
+ run: ((cwd: string, args: string[], opts?: never) => {
226
+ calls.push({ args, held: repository.depth > 0 });
227
+ return runner.run(cwd, args, opts);
228
+ }) as NodeGitRunner['run'],
229
+ };
230
+ const service = new GitService(recorded, new WorkflowHooks(), testKbContext(), mutex, null, recordingRunner);
231
+ await recorded.getOrCreateForBranch(TARGET);
232
+ await recorded.getOrCreateForBranch('alice/a');
233
+
234
+ await service.fetch(id(TARGET));
235
+ await service.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
236
+ await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
237
+ await service.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits');
238
+ await service.push(id('alice/a'), ALICE);
239
+ await service.status(id('alice/a'));
240
+ // A merge stamps the committer's name and email into the config.
241
+ const merged = await service.mergeChangeRequest(
242
+ id(TARGET), 'alice/a', TARGET, { subject: 'alice edits (#1)', body: '' }, ALICE,
243
+ );
244
+ expect(merged.kind).toBe('merged');
245
+ await service.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
246
+
247
+ // The subcommand, past leading `-c key=value` / `-C dir` options.
248
+ const sub = (args: string[]) => {
249
+ let i = 0;
250
+ while (args[i] === '-c' || args[i] === '-C') i += 2;
251
+ return { name: args[i], rest: args.slice(i + 1) };
252
+ };
253
+ const of = (pred: (s: { name: string; rest: string[] }) => boolean) => calls.filter((c) => pred(sub(c.args)));
254
+
255
+ // The config is the one file git refuses a second writer of: every write
256
+ // to it (the upstream stamp, the committer's name and email, a branch
257
+ // delete that drops the branch's section) is the administrator's. The
258
+ // refs, packed-refs and the objects git locks itself, so a fetch, a
259
+ // commit, a push, a merge and a read run as any worktree's git would.
260
+ const configRead = (rest: string[]) => rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(a));
261
+ const shared = [
262
+ ...of((s) => s.name === 'config' && !configRead(s.rest)),
263
+ ...of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')),
264
+ ];
265
+ const own = [
266
+ ...of((s) => s.name === 'fetch'),
267
+ ...of((s) => s.name === 'commit'),
268
+ ...of((s) => s.name === 'merge'),
269
+ ...of((s) => s.name === 'push' && !s.rest.includes('-u')),
270
+ ...of((s) => s.name === 'status'),
271
+ ...of((s) => s.name === 'config' && configRead(s.rest)),
272
+ ];
273
+ for (const name of ['fetch', 'config', 'commit', 'merge', 'push', 'status']) {
274
+ expect(calls.some((c) => sub(c.args).name === name), name).toBe(true);
275
+ }
276
+ expect(of((s) => s.name === 'config' && s.rest.includes('--replace-all')).length).toBeGreaterThan(0);
277
+ expect(of((s) => s.name === 'config' && s.rest[0] === 'user.name').length).toBeGreaterThan(0);
278
+ expect(of((s) => s.name === 'config' && s.rest[0] === 'user.email').length).toBeGreaterThan(0);
279
+ expect(of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')).length).toBeGreaterThan(0);
280
+ expect(shared.length).toBeGreaterThan(3);
281
+ for (const c of shared) expect(c.held, c.args.join(' ')).toBe(true);
282
+ for (const c of own) expect(c.held, c.args.join(' ')).toBe(false);
283
+ });
284
+ });
@@ -25,6 +25,7 @@ import { WorkspaceMutex } from '../../kb-fs/mutex.js';
25
25
  import { isAbsence } from '../../../shared/fs.contract.js';
26
26
  import { assertInsideRepo } from '../../kb-fs/repo-path.js';
27
27
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
28
+ import { readRefSha } from '../../kb-fs/index.js';
28
29
  import {
29
30
  assertValidBranchName,
30
31
  assertValidRelativePath,
@@ -294,6 +295,25 @@ export class GitService implements IGitService {
294
295
  // counts, calm under large branch lists.
295
296
  private static readonly MAX_AHEAD_BEHIND_CONCURRENCY = 8;
296
297
 
298
+ /**
299
+ * A change request's changed entries, keyed by the two commits its diff
300
+ * was read between. Two commits never change, and neither does the
301
+ * merge-base between them, so the three-dot diff of a pair is a fact: a
302
+ * push moves a tip, the pair changes, the old entry is simply never asked
303
+ * for again. No TTL, no invalidation.
304
+ *
305
+ * This is what a change-request listing was paying git for on every read:
306
+ * one `diff` per open request, polled every minute and refetched on every
307
+ * event, where nothing had moved. Now git is asked once per pair, which is
308
+ * once per push. Keyed by commits alone, not by workspace: every clone of
309
+ * the knowledge base holds the same objects, so the answer is the same in
310
+ * all of them. Bounded FIFO; the bound only caps memory.
311
+ */
312
+ private readonly changedEntriesByCommits = new Map<string, NameStatusEntry[]>();
313
+ /** Diffs being read right now, so concurrent misses on one pair share one `git diff`. */
314
+ private readonly changedEntriesInFlight = new Map<string, Promise<NameStatusEntry[]>>();
315
+ private static readonly CHANGED_ENTRIES_MAX = 4096;
316
+
297
317
  constructor(
298
318
  private readonly workspaceService: WorkspaceService,
299
319
  /**
@@ -378,7 +398,9 @@ export class GitService implements IGitService {
378
398
  * above rather than kept separately, so it cannot disagree with it.
379
399
  */
380
400
  lastRemoteContact(): { at: number; ok: boolean } | null {
381
- let latest: { at: number; ok: boolean } | null = null;
401
+ // The repository's fetches are every worktree's; the per-workspace record
402
+ // is a full clone's.
403
+ let latest: { at: number; ok: boolean } | null = this.workspaceService.repository?.lastContact() ?? null;
382
404
  for (const [workspaceId, at] of this.lastImplicitFetchAt) {
383
405
  if (latest === null || at > latest.at) {
384
406
  latest = { at, ok: this.lastImplicitFetchOk.get(workspaceId) ?? false };
@@ -495,12 +517,27 @@ export class GitService implements IGitService {
495
517
  // Union local heads with origin's remote-tracking refs: a fresh per-user
496
518
  // clone only has one local head (HEAD's default), so without this we'd hide
497
519
  // target-company-state and every draft pushed by another user.
520
+ //
521
+ // The workspace is a worktree of the one repository, which holds every
522
+ // OTHER worktree's local branch too. Those are not this workspace's: a
523
+ // clone never had them. A local head checked out in another worktree
524
+ // is read as that branch's remote-tracking ref alone (and not at all
525
+ // once origin no longer has it), so a branch deleted on origin is not
526
+ // kept alive by its own worktree's local ref — the orphan sweep reads
527
+ // this list. A branch is checked out in at most one worktree, so "not
528
+ // this one's HEAD" is "another's".
498
529
  const { stdout } = await this.git(cwd, [
499
530
  'for-each-ref',
500
- '--format=%(refname:short)%09%(refname)',
531
+ '--format=%(refname:short)%09%(refname)%09%(worktreepath)',
501
532
  'refs/heads',
502
533
  'refs/remotes/origin',
503
534
  ]);
535
+ const ownBranch = (
536
+ await this.git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(
537
+ (r) => r.stdout,
538
+ () => '',
539
+ )
540
+ ).trim();
504
541
 
505
542
  type Row = { shortName: string; ref: string; hasLocal: boolean; hasRemote: boolean };
506
543
  const buckets = new Map<string, Row>();
@@ -510,7 +547,7 @@ export class GitService implements IGitService {
510
547
  const tab = trimmed.indexOf('\t');
511
548
  if (tab < 0) continue;
512
549
  const rawShort = trimmed.slice(0, tab);
513
- const ref = trimmed.slice(tab + 1);
550
+ const [ref = '', worktreePath = ''] = trimmed.slice(tab + 1).split('\t');
514
551
  if (!rawShort || !ref) continue;
515
552
  // Skip symbolic refs up-front. Different git versions report
516
553
  // `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
@@ -521,6 +558,7 @@ export class GitService implements IGitService {
521
558
  const isLocal = ref.startsWith('refs/heads/');
522
559
  const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
523
560
  if (!shortName) continue;
561
+ if (isLocal && worktreePath !== '' && shortName !== ownBranch) continue;
524
562
  // Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
525
563
  // if a future git version lands us here, don't bucket it as a branch.
526
564
  if (shortName === 'HEAD') continue;
@@ -614,6 +652,20 @@ export class GitService implements IGitService {
614
652
  workspaceId: string,
615
653
  force = false,
616
654
  ): Promise<boolean> {
655
+ // A worktree's remote-tracking refs are the repository's: ONE fetch for
656
+ // every workspace, throttled, joined and reserved there. The per-workspace
657
+ // record below is for a full clone from an earlier release, which fetches
658
+ // on its own until the boot phase converts it.
659
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
660
+ if (repository) {
661
+ return repository.fetch({
662
+ maxAgeMs: GitService.IMPLICIT_FETCH_TTL_MS,
663
+ // A host that is down is not asked again by every poll inside the
664
+ // window, as the per-workspace record below stamps a failure too.
665
+ failureBackoffMs: GitService.IMPLICIT_FETCH_TTL_MS,
666
+ force,
667
+ });
668
+ }
617
669
  const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
618
670
  if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
619
671
  return this.lastImplicitFetchOk.get(workspaceId) ?? true;
@@ -692,8 +744,9 @@ export class GitService implements IGitService {
692
744
  // navigation to the new draft's URL fails with
693
745
  // "Remote branch <name> not found in upstream origin". Roll the local
694
746
  // ref back on failure so a retry isn't blocked by "branch already exists".
747
+ // No `-u`: the local head is dropped right below, and its upstream with it.
695
748
  try {
696
- await this.git(cwd, ['push', '-u', 'origin', name]);
749
+ await this.git(cwd, ['push', 'origin', name]);
697
750
  } catch (err) {
698
751
  await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
699
752
  throw err;
@@ -829,7 +882,14 @@ export class GitService implements IGitService {
829
882
  let localTip: string | null = null;
830
883
  if (await this.refExists(cwd, `refs/heads/${name}`)) {
831
884
  localTip = await this.revParseOrNull(cwd, `refs/heads/${name}`);
832
- await this.git(cwd, ['branch', '-D', name]);
885
+ await this.git(cwd, ['branch', '-D', name]).catch((err: unknown) => {
886
+ // In a repository of worktrees the branch's local ref is shared, and
887
+ // git keeps it while the branch's own workspace has it checked out.
888
+ // Removing that workspace — which the caller does next — takes the
889
+ // worktree and the ref with it.
890
+ if (/checked out at|used by worktree/i.test(err instanceof Error ? err.message : String(err))) return;
891
+ throw err;
892
+ });
833
893
  }
834
894
  return { lastCommit: remoteTip ?? localTip };
835
895
  });
@@ -1409,7 +1469,14 @@ export class GitService implements IGitService {
1409
1469
  );
1410
1470
  }
1411
1471
 
1412
- await this.git(cwd, ['push', '-u', 'origin', branch]);
1472
+ // No `-u`: it writes the upstream into the config during the push, and
1473
+ // a worktree's config is the repository's, shared by every workspace —
1474
+ // git refuses a second writer of it rather than waiting. The upstream is
1475
+ // stamped after the push instead, as one short write made as the
1476
+ // repository's administrator (see `git`).
1477
+ await this.git(cwd, ['push', 'origin', branch]);
1478
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
1479
+ await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
1413
1480
  }
1414
1481
  }
1415
1482
 
@@ -3166,9 +3233,8 @@ export class GitService implements IGitService {
3166
3233
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3167
3234
  }
3168
3235
  return this.mutex.run(workspaceId, async () => {
3169
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3170
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3171
- return this.prChangedPathsAt(cwd, baseRef, headRef, opts);
3236
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3237
+ return flattenChangedPaths(await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head), opts);
3172
3238
  });
3173
3239
  }
3174
3240
 
@@ -3243,13 +3309,51 @@ export class GitService implements IGitService {
3243
3309
  await this.fetchPrRefs(cwd, baseBranch, headBranch);
3244
3310
  }
3245
3311
  return this.mutex.run(workspaceId, async () => {
3246
- const baseRef = pinned ? pinned.base : await this.resolvePublishedBranchRef(cwd, baseBranch);
3247
- const headRef = pinned ? pinned.head : await this.resolvePublishedBranchRef(cwd, headBranch);
3248
- const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
3312
+ const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
3313
+ const entries = await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head);
3249
3314
  return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
3250
3315
  });
3251
3316
  }
3252
3317
 
3318
+ /**
3319
+ * {@link prChangedEntriesAt} for a pair of COMMITS, read once per pair
3320
+ * (see `changedEntriesByCommits`). The caller holds the reservation, as for
3321
+ * `prChangedEntriesAt`; a memo hit holds it for nothing, which is cheaper
3322
+ * than a second code path around the lock.
3323
+ */
3324
+ private async prChangedEntriesBetweenCommits(
3325
+ cwd: string,
3326
+ baseSha: string,
3327
+ headSha: string,
3328
+ ): Promise<NameStatusEntry[]> {
3329
+ const key = `${baseSha}\0${headSha}`;
3330
+ const known = this.changedEntriesByCommits.get(key);
3331
+ if (known) return known;
3332
+ let pending = this.changedEntriesInFlight.get(key);
3333
+ if (!pending) {
3334
+ pending = this.prChangedEntriesAt(cwd, baseSha, headSha)
3335
+ .then((entries) => {
3336
+ if (this.changedEntriesByCommits.size >= GitService.CHANGED_ENTRIES_MAX) {
3337
+ const oldest = this.changedEntriesByCommits.keys().next().value;
3338
+ if (oldest !== undefined) this.changedEntriesByCommits.delete(oldest);
3339
+ }
3340
+ this.changedEntriesByCommits.set(key, entries);
3341
+ return entries;
3342
+ })
3343
+ .finally(() => {
3344
+ if (this.changedEntriesInFlight.get(key) === pending) this.changedEntriesInFlight.delete(key);
3345
+ });
3346
+ // Same bound as the memo: evicting an in-flight entry costs at most one
3347
+ // redundant diff for that pair, never a lost answer.
3348
+ if (this.changedEntriesInFlight.size >= GitService.CHANGED_ENTRIES_MAX) {
3349
+ const oldest = this.changedEntriesInFlight.keys().next().value;
3350
+ if (oldest !== undefined) this.changedEntriesInFlight.delete(oldest);
3351
+ }
3352
+ this.changedEntriesInFlight.set(key, pending);
3353
+ }
3354
+ return pending;
3355
+ }
3356
+
3253
3357
  /**
3254
3358
  * The merge-base commit of a change request's two branches (their origin
3255
3359
  * refs), or null when they share no history. This is the "before" the CR's
@@ -3590,16 +3694,23 @@ export class GitService implements IGitService {
3590
3694
  baseBranch: string,
3591
3695
  headBranch: string,
3592
3696
  ): Promise<{ base: string; head: string } | null> {
3593
- const shas: string[] = [];
3594
- for (const branch of [baseBranch, headBranch]) {
3595
- try {
3596
- shas.push((await this.resolvePublishedBranch(cwd, branch)).sha);
3597
- } catch (err) {
3598
- if (err instanceof WorkflowValidationError) return null;
3599
- throw err;
3600
- }
3697
+ try {
3698
+ return await this.publishedPrEnds(cwd, baseBranch, headBranch);
3699
+ } catch (err) {
3700
+ if (err instanceof WorkflowValidationError) return null;
3701
+ throw err;
3601
3702
  }
3602
- return { base: shas[0], head: shas[1] };
3703
+ }
3704
+
3705
+ /** The two commits a change request is read between, each resolved as {@link resolvePublishedBranch} does. */
3706
+ private async publishedPrEnds(
3707
+ cwd: string,
3708
+ baseBranch: string,
3709
+ headBranch: string,
3710
+ ): Promise<{ base: string; head: string }> {
3711
+ const base = (await this.resolvePublishedBranch(cwd, baseBranch)).sha;
3712
+ const head = (await this.resolvePublishedBranch(cwd, headBranch)).sha;
3713
+ return { base, head };
3603
3714
  }
3604
3715
 
3605
3716
  /**
@@ -3625,6 +3736,13 @@ export class GitService implements IGitService {
3625
3736
  branch: string,
3626
3737
  ): Promise<{ ref: string; sha: string }> {
3627
3738
  for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
3739
+ // The ref file first (`readRefSha`): no process, same answer, same
3740
+ // freshness. Git is asked only for what the files cannot decide — a
3741
+ // missing ref is decided by the files and the loop moves on; an
3742
+ // unreadable one is git's to answer, as the authority.
3743
+ const fromFiles = await readRefSha(cwd, ref);
3744
+ if (fromFiles === 'missing') continue;
3745
+ if (fromFiles !== 'unreadable') return { ref, sha: fromFiles.sha };
3628
3746
  try {
3629
3747
  const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
3630
3748
  return { ref, sha: stdout.trim() };
@@ -4017,6 +4135,29 @@ export class GitService implements IGitService {
4017
4135
  return stdout.split('\0').filter((line) => line.length > 0);
4018
4136
  }
4019
4137
 
4138
+ /**
4139
+ * The commit `ref` names right now, or null when it names none (an unborn
4140
+ * `HEAD`, a ref that does not exist). A caller that reads several things
4141
+ * "at HEAD" pins the commit once with this and asks each read at that
4142
+ * commit, so a commit landing between the reads cannot hand it one
4143
+ * snapshot's files with another's rules.
4144
+ *
4145
+ * Null is ONLY "names no commit": `rev-parse --verify --quiet` says that
4146
+ * with exit status 1 and nothing on stderr. Any other failure — no
4147
+ * repository there, git itself failing, a deadline — is thrown, so a
4148
+ * caller cannot read a broken clone as an empty one.
4149
+ */
4150
+ async commitAtRef(workspaceId: string, ref: string): Promise<string | null> {
4151
+ const cwd = await this.repoDir(workspaceId);
4152
+ try {
4153
+ const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
4154
+ return stdout.trim() || null;
4155
+ } catch (err) {
4156
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && !(err.stderr ?? '').trim()) return null;
4157
+ throw err;
4158
+ }
4159
+ }
4160
+
4020
4161
  /**
4021
4162
  * A clean `git status` for a path has two honest readings (already
4022
4163
  * committed; a queued re-apply of something that landed) and one dishonest
@@ -4074,7 +4215,7 @@ export class GitService implements IGitService {
4074
4215
  * from a real failure are unaffected.
4075
4216
  */
4076
4217
  private git(cwd: string, args: string[], opts?: Omit<GitRunOptions, 'encoding'>): Promise<GitRunResult> {
4077
- return this.gitRunner.run(cwd, args, {
4218
+ const run = () => this.gitRunner.run(cwd, args, {
4078
4219
  ...opts,
4079
4220
  // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
4080
4221
  // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as glob
@@ -4089,6 +4230,18 @@ export class GitService implements IGitService {
4089
4230
  // a search for a file literally named that.
4090
4231
  env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
4091
4232
  });
4233
+ // A write to the git config is a write to the ONE file every worktree of
4234
+ // the repository shares, which git refuses a second writer of rather than
4235
+ // waiting: it runs as the repository's administrator. Everything else —
4236
+ // a fetch, a commit, a branch delete — git serializes with its own locks,
4237
+ // as it does for any two worktrees of one repository.
4238
+ return writesSharedConfig(args) ? this.asRepositoryAdministrator(cwd, run) : run();
4239
+ }
4240
+
4241
+ /** `run` as the repository's administrator when `cwd` is a worktree of it; as itself for a full clone. */
4242
+ private async asRepositoryAdministrator<T>(cwd: string, run: () => Promise<T>): Promise<T> {
4243
+ const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
4244
+ return repository ? repository.exclusive(run) : run();
4092
4245
  }
4093
4246
 
4094
4247
  /**
@@ -4276,3 +4429,35 @@ export function parseNumstatZ(out: string): NumstatEntry[] {
4276
4429
  }
4277
4430
  return entries;
4278
4431
  }
4432
+
4433
+ /**
4434
+ * Whether a git command WRITES the config — the one file every worktree of
4435
+ * the knowledge base's repository shares, which git refuses a second writer
4436
+ * of rather than waiting — and so must run as the repository's administrator
4437
+ * (`IKbRepository.exclusive`): a `config` that sets or unsets, a `remote`
4438
+ * change, a branch rename, delete or upstream change (its config section is
4439
+ * written, or removed with the branch), a push that sets the upstream. Reads
4440
+ * of the config are not writes. Nothing else is: the refs, `packed-refs` and
4441
+ * the object store are locked by git itself, and the worktree list is the
4442
+ * repository's own to serialize (`KbRepository`).
4443
+ *
4444
+ * Leading `-c key=value` / `-C dir` options are skipped to find the subcommand.
4445
+ */
4446
+ function writesSharedConfig(args: readonly string[]): boolean {
4447
+ let i = 0;
4448
+ while (i < args.length && (args[i] === '-c' || args[i] === '-C')) i += 2;
4449
+ const sub = args[i];
4450
+ const rest = args.slice(i + 1);
4451
+ switch (sub) {
4452
+ case 'remote':
4453
+ return true;
4454
+ case 'config':
4455
+ return !rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l|--show-origin)$/.test(a));
4456
+ case 'branch':
4457
+ return rest.some((a) => /^(-d|-D|--delete|-m|-M|--move|-u|--set-upstream-to=.*|--unset-upstream)$/.test(a));
4458
+ case 'push':
4459
+ return rest.some((a) => a === '-u' || a === '--set-upstream');
4460
+ default:
4461
+ return false;
4462
+ }
4463
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What the workflow module offers the other modules: the addresses of its
3
+ * change requests, so a module that lands a proposal can hand back a link
4
+ * built by the one rule — rather than each spelling the route for itself.
5
+ * The services are wired by the composition root.
6
+ */
7
+ export { CHANGE_REQUEST_URL_NOTE, changeRequestLink, changeRequestLinkBase } from './git/change-request-link.js';