@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,620 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } 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 { NodeFs } from '../../kb-fs/node-fs.js';
9
+ import { KbRepository, workingCopyShape } from '../kb-repository.js';
10
+ import { setAsideRepository } from '../set-aside-clone.js';
11
+ import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
12
+ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
13
+ import type { IGitRunner } from '../../../shared/git.contract.js';
14
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
15
+
16
+ /**
17
+ * Branch workspaces are WORKTREES of one repository per knowledge base, at the
18
+ * path a full clone had (`<workspacesRoot>/<branch id>/<kb folder>`). Real git
19
+ * throughout: what is under test is what git does with the layout.
20
+ */
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ /**
25
+ * A path as git prints it: on Windows git writes `C:/x/y` where node writes
26
+ * `C:\x\y`, so both sides of a comparison go through this.
27
+ */
28
+ const asGit = (p: string): string => p.replace(/\\/g, '/');
29
+
30
+ async function git(cwd: string, args: string[]): Promise<string> {
31
+ const { stdout } = await execFileAsync('git', args, {
32
+ cwd,
33
+ env: {
34
+ ...process.env,
35
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
36
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
37
+ },
38
+ });
39
+ return stdout.toString();
40
+ }
41
+
42
+ /** Every git call the service makes, with when it started and ended. */
43
+ interface Call {
44
+ cwd: string;
45
+ args: string[];
46
+ start: number;
47
+ end?: number;
48
+ }
49
+
50
+ function recordingRunner(delayFetchMs = 0): { runner: IGitRunner; calls: Call[] } {
51
+ const inner = new NodeGitRunner();
52
+ const calls: Call[] = [];
53
+ const runner = {
54
+ defaultTimeoutMs: inner.defaultTimeoutMs,
55
+ credentials: inner.credentials,
56
+ run: async (cwd: string, args: string[], opts?: object) => {
57
+ const call: Call = { cwd, args, start: performance.now() };
58
+ calls.push(call);
59
+ try {
60
+ if (delayFetchMs > 0 && args.includes('fetch')) await new Promise((r) => setTimeout(r, delayFetchMs));
61
+ return await inner.run(cwd, args, opts as never);
62
+ } finally {
63
+ call.end = performance.now();
64
+ }
65
+ },
66
+ } as unknown as IGitRunner;
67
+ return { runner, calls };
68
+ }
69
+
70
+ /** The repository-wide `fetch --prune origin` — the one every workspace shares. */
71
+ const repositoryFetches = (calls: Call[]) =>
72
+ calls.filter((c) => c.args.includes('fetch') && c.args.includes('--prune') && c.args[c.args.length - 1] === 'origin');
73
+
74
+ let root: string;
75
+ let workspacesRoot: string;
76
+ let upstream: string;
77
+ let seed: string;
78
+
79
+ beforeEach(async () => {
80
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-worktrees-'));
81
+ workspacesRoot = path.join(root, 'workspaces');
82
+ await fs.mkdir(workspacesRoot, { recursive: true });
83
+ upstream = path.join(root, 'upstream.git');
84
+ await git(root, ['init', '--bare', '-b', 'main', upstream]);
85
+ seed = path.join(root, '.seed');
86
+ await fs.mkdir(seed);
87
+ await git(seed, ['init', '-b', 'main']);
88
+ await git(seed, ['remote', 'add', 'origin', upstream]);
89
+ await fs.writeFile(path.join(seed, 'shared.md'), 'on main\n');
90
+ await fs.mkdir(path.join(seed, 'Deep', 'Folder'), { recursive: true });
91
+ await fs.writeFile(path.join(seed, 'Deep', 'Folder', 'page.md'), 'deep\n');
92
+ await git(seed, ['add', '-A']);
93
+ await git(seed, ['commit', '-m', 'init']);
94
+ for (const branch of ['alice/a', 'bob/b', 'carol/c']) {
95
+ await git(seed, ['checkout', '-q', '-b', branch, 'main']);
96
+ await fs.writeFile(path.join(seed, `${branch.split('/')[0]}.md`), `${branch}\n`);
97
+ await git(seed, ['add', '-A']);
98
+ await git(seed, ['commit', '-m', `on ${branch}`]);
99
+ }
100
+ await git(seed, ['checkout', '-q', 'main']);
101
+ await git(seed, ['push', '-q', 'origin', 'main', 'alice/a', 'bob/b', 'carol/c']);
102
+ });
103
+
104
+ afterEach(async () => {
105
+ vi.restoreAllMocks();
106
+ await fs.rm(root, { recursive: true, force: true });
107
+ });
108
+
109
+ const service = (runner: IGitRunner = new NodeGitRunner(), url = upstream, setAsideRoot?: string) =>
110
+ newWorkspaceService(workspacesRoot, url, testKbContext(), new NodeFs(), runner, setAsideRoot);
111
+
112
+ const checkoutOf = (branch: string) => path.join(workspacesRoot, workspaceIdForBranch(branch), 'knowledge-base');
113
+
114
+ /** Every folder named `objects` under `dir` — where a clone keeps its history. */
115
+ async function objectStoresUnder(dir: string): Promise<string[]> {
116
+ const found: string[] = [];
117
+ for (const entry of await fs.readdir(dir, { recursive: true, withFileTypes: true })) {
118
+ if (entry.isDirectory() && entry.name === 'objects') found.push(path.join(entry.parentPath, entry.name));
119
+ }
120
+ return found;
121
+ }
122
+
123
+ describe('opening a branch', () => {
124
+ it('adds a worktree at the path a clone had, on its branch, tracking origin, with the files a fresh clone has — and copies no history', async () => {
125
+ const svc = service();
126
+ const info = await svc.getOrCreateForBranch('alice/a');
127
+ const checkout = checkoutOf('alice/a');
128
+
129
+ expect(info.absolutePath).toBe(path.join(workspacesRoot, workspaceIdForBranch('alice/a')));
130
+ expect(await workingCopyShape(checkout)).toBe('worktree');
131
+ expect((await fs.stat(path.join(checkout, '.git'))).isFile()).toBe(true);
132
+ expect((await git(checkout, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('alice/a');
133
+ expect((await git(checkout, ['rev-parse', '--abbrev-ref', '@{upstream}'])).trim()).toBe('origin/alice/a');
134
+
135
+ // Byte-identical to a fresh clone of the branch: same commit, same index, same bytes.
136
+ const fresh = path.join(root, 'fresh');
137
+ await git(root, ['clone', '-q', '-b', 'alice/a', upstream, fresh]);
138
+ expect(await git(checkout, ['rev-parse', 'HEAD'])).toBe(await git(fresh, ['rev-parse', 'HEAD']));
139
+ expect(await git(checkout, ['ls-files', '-s'])).toBe(await git(fresh, ['ls-files', '-s']));
140
+ for (const file of ['shared.md', 'alice.md', path.join('Deep', 'Folder', 'page.md')]) {
141
+ expect(await fs.readFile(path.join(checkout, file))).toEqual(await fs.readFile(path.join(fresh, file)));
142
+ }
143
+
144
+ // No history under the workspaces root: the objects are the repository's,
145
+ // and the repository is not under the root.
146
+ expect(await objectStoresUnder(workspacesRoot)).toEqual([]);
147
+ expect(path.relative(workspacesRoot, svc.repository.dir).startsWith('..')).toBe(true);
148
+ expect(asGit((await git(checkout, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim())).toBe(
149
+ asGit(await fs.realpath(svc.repository.dir)),
150
+ );
151
+ });
152
+
153
+ it("lists and reads the worktree's files and never its `.git` file", async () => {
154
+ const svc = service();
155
+ await svc.getOrCreateForBranch('alice/a');
156
+ const id = workspaceIdForBranch('alice/a');
157
+
158
+ const names: string[] = [];
159
+ const walk = (entry: { name: string; children?: unknown[] }) => {
160
+ names.push(entry.name);
161
+ for (const child of (entry.children ?? []) as Array<{ name: string; children?: unknown[] }>) walk(child);
162
+ };
163
+ walk((await svc.listFiles(id)) as never);
164
+ expect(names).toContain('alice.md');
165
+ expect(names).not.toContain('.git');
166
+ const all = await svc.readAllKbFiles(id);
167
+ expect(Object.keys(all).some((p) => p.split('/').includes('.git'))).toBe(false);
168
+ expect(Object.keys(all).length).toBeGreaterThan(0);
169
+ });
170
+
171
+ it('makes and fetches the repository for the first branch, and the second reuses it', async () => {
172
+ const { runner, calls } = recordingRunner();
173
+ const svc = service(runner);
174
+ await svc.getOrCreateForBranch('alice/a');
175
+ await svc.getOrCreateForBranch('bob/b');
176
+
177
+ expect(calls.filter((c) => c.args[0] === 'init')).toHaveLength(1);
178
+ expect(calls.filter((c) => c.args[0] === 'clone')).toHaveLength(0);
179
+ expect(calls.filter((c) => c.args[0] === 'worktree' && c.args[1] === 'add')).toHaveLength(2);
180
+ const listed = asGit(await git(svc.repository.dir, ['worktree', 'list', '--porcelain']));
181
+ expect(listed).toContain(asGit(await fs.realpath(checkoutOf('alice/a'))));
182
+ expect(listed).toContain(asGit(await fs.realpath(checkoutOf('bob/b'))));
183
+ });
184
+
185
+ it('is treated as unfinished and recreated when the worktree has lost its link to the repository', async () => {
186
+ const svc = service();
187
+ await svc.getOrCreateForBranch('alice/a');
188
+ const checkout = checkoutOf('alice/a');
189
+ // The repository's half of the link goes, as when a worktree is pruned
190
+ // from under its folder.
191
+ const gitdir = (await fs.readFile(path.join(checkout, '.git'), 'utf-8')).replace(/^gitdir:\s*/, '').trim();
192
+ await fs.rm(gitdir, { recursive: true, force: true });
193
+ expect(await workingCopyShape(checkout)).toBe('broken');
194
+
195
+ // A process that never saw it (a restart) opens the branch.
196
+ const after = service();
197
+ await after.getOrCreateForBranch('alice/a');
198
+ expect(await workingCopyShape(checkout)).toBe('worktree');
199
+ expect((await git(checkout, ['rev-parse', 'HEAD'])).trim()).toBe(
200
+ (await git(upstream, ['rev-parse', 'alice/a'])).trim(),
201
+ );
202
+ });
203
+
204
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
205
+ 'is not recreated when its link to the repository cannot be read (EACCES is not absence)',
206
+ async () => {
207
+ const svc = service();
208
+ await svc.getOrCreateForBranch('alice/a');
209
+ const checkout = checkoutOf('alice/a');
210
+ await fs.writeFile(path.join(checkout, 'unsaved.md'), 'kept');
211
+ const gitdir = (await fs.readFile(path.join(checkout, '.git'), 'utf-8')).replace(/^gitdir:\s*/, '').trim();
212
+ const records = path.dirname(gitdir);
213
+ await fs.chmod(records, 0o000);
214
+ try {
215
+ await expect(workingCopyShape(checkout)).rejects.toMatchObject({ code: 'EACCES' });
216
+ await expect(service().getOrCreateForBranch('alice/a')).rejects.toBeDefined();
217
+ } finally {
218
+ await fs.chmod(records, 0o755);
219
+ }
220
+ expect(await fs.readFile(path.join(checkout, 'unsaved.md'), 'utf-8')).toBe('kept');
221
+ expect(await workingCopyShape(checkout)).toBe('worktree');
222
+ },
223
+ );
224
+ });
225
+
226
+ describe('fetching', () => {
227
+ it('runs ONE git fetch when three workspaces are active and a fetch is due, and every workspace sees what it brought', async () => {
228
+ const { runner, calls } = recordingRunner();
229
+ const svc = service(runner);
230
+ for (const b of ['alice/a', 'bob/b', 'carol/c']) await svc.getOrCreateForBranch(b);
231
+
232
+ // A change pushed to the remote, from outside.
233
+ await fs.writeFile(path.join(seed, 'shared.md'), 'moved on\n');
234
+ await git(seed, ['commit', '-qam', 'moved on']);
235
+ await git(seed, ['push', '-q', 'origin', 'main']);
236
+ const pushed = (await git(seed, ['rev-parse', 'HEAD'])).trim();
237
+
238
+ // The freshness window has run out.
239
+ const now = Date.now();
240
+ vi.spyOn(Date, 'now').mockReturnValue(now + 60_000);
241
+ const before = repositoryFetches(calls).length;
242
+ await Promise.all(['alice/a', 'bob/b', 'carol/c'].map((b) => svc.ensureRemotesFetched(workspaceIdForBranch(b))));
243
+ expect(repositoryFetches(calls).length - before).toBe(1);
244
+ // And inside the window, none.
245
+ await Promise.all(['alice/a', 'bob/b', 'carol/c'].map((b) => svc.ensureRemotesFetched(workspaceIdForBranch(b))));
246
+ expect(repositoryFetches(calls).length - before).toBe(1);
247
+
248
+ for (const b of ['alice/a', 'bob/b', 'carol/c']) {
249
+ expect((await git(checkoutOf(b), ['rev-parse', 'origin/main'])).trim()).toBe(pushed);
250
+ }
251
+ });
252
+
253
+ it('makes a fetch and a worktree add requested at the same time wait for one another', async () => {
254
+ const { runner, calls } = recordingRunner(150);
255
+ const svc = service(runner);
256
+ await svc.getOrCreateForBranch('alice/a');
257
+ const now = Date.now();
258
+ vi.spyOn(Date, 'now').mockReturnValue(now + 60_000);
259
+
260
+ await Promise.all([svc.ensureRemotesFetched(workspaceIdForBranch('alice/a')), svc.getOrCreateForBranch('bob/b')]);
261
+
262
+ const fetch = repositoryFetches(calls).at(-1)!;
263
+ const add = calls.find((c) => c.args[0] === 'worktree' && c.args[1] === 'add' && c.args.includes('bob/b'))!;
264
+ // One ran entirely before the other.
265
+ expect(fetch.end! <= add.start || add.end! <= fetch.start).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe('re-pointing the repository at a new address', () => {
270
+ it('forgets the fresh fetch from the old address, so the next one reaches the new address', async () => {
271
+ const { runner, calls } = recordingRunner();
272
+ const svc = service(runner);
273
+ await svc.getOrCreateForBranch('alice/a');
274
+ expect(await svc.repository.fetch({ maxAgeMs: 60_000, force: true })).toBe(true);
275
+ // The same repository, moved.
276
+ const moved = path.join(root, 'moved.git');
277
+ await fs.cp(upstream, moved, { recursive: true });
278
+ await svc.repository.setOriginUrl(moved);
279
+ expect(svc.repository.lastContact()).toBeNull();
280
+
281
+ const before = repositoryFetches(calls).length;
282
+ expect(await svc.repository.fetch({ maxAgeMs: 60_000 })).toBe(true);
283
+ expect(repositoryFetches(calls).length).toBe(before + 1);
284
+ });
285
+ });
286
+
287
+ describe('deleting a branch workspace', () => {
288
+ it('removes and prunes its worktree and nothing else', async () => {
289
+ const svc = service();
290
+ await svc.getOrCreateForBranch('alice/a');
291
+ await svc.getOrCreateForBranch('bob/b');
292
+ const aliceTip = (await git(checkoutOf('alice/a'), ['rev-parse', 'HEAD'])).trim();
293
+ const aliceCheckout = asGit(await fs.realpath(checkoutOf('alice/a')));
294
+
295
+ await svc.deleteWorkspace(workspaceIdForBranch('alice/a'));
296
+
297
+ await expect(fs.access(path.join(workspacesRoot, workspaceIdForBranch('alice/a')))).rejects.toThrow();
298
+ expect(asGit(await git(svc.repository.dir, ['worktree', 'list', '--porcelain']))).not.toContain(aliceCheckout);
299
+ // The repository keeps its objects…
300
+ await git(svc.repository.dir, ['cat-file', '-e', `${aliceTip}^{commit}`]);
301
+ await git(svc.repository.dir, ['fsck', '--connectivity-only', '--no-dangling']);
302
+ // …and the other workspace answers as before.
303
+ expect(await svc.readFile(workspaceIdForBranch('bob/b'), 'knowledge-base/bob.md')).toBe('bob/b\n');
304
+ expect((await git(checkoutOf('bob/b'), ['status', '--porcelain'])).trim()).toBe('');
305
+ });
306
+
307
+ it('the orphan sweep removes the folders of branches that are gone and never the repository', async () => {
308
+ const svc = service();
309
+ await svc.getOrCreateForBranch('alice/a');
310
+ await svc.getOrCreateForBranch('bob/b');
311
+ const aliceCheckout = asGit(await fs.realpath(checkoutOf('alice/a')));
312
+
313
+ const { removed } = await svc.sweepOrphanedWorkspaces(['bob/b']);
314
+
315
+ expect(removed).toEqual([workspaceIdForBranch('alice/a')]);
316
+ expect(await svc.repository.exists()).toBe(true);
317
+ expect(asGit(await git(svc.repository.dir, ['worktree', 'list', '--porcelain']))).not.toContain(aliceCheckout);
318
+ expect(await svc.readFile(workspaceIdForBranch('bob/b'), 'knowledge-base/bob.md')).toBe('bob/b\n');
319
+ });
320
+ });
321
+
322
+ describe('commits the remote does not have', () => {
323
+ it('stay on the branch in the repository when a push fails, the next push carries them, and no other worktree sees them', async () => {
324
+ const svc = service();
325
+ await svc.getOrCreateForBranch('alice/a');
326
+ await svc.getOrCreateForBranch('bob/b');
327
+ const alice = checkoutOf('alice/a');
328
+ await fs.writeFile(path.join(alice, 'alice.md'), 'unpushed\n');
329
+ await git(alice, ['commit', '-qam', 'unpushed work']);
330
+ const commit = (await git(alice, ['rev-parse', 'HEAD'])).trim();
331
+
332
+ // The host refuses the push.
333
+ const hook = path.join(upstream, 'hooks', 'pre-receive');
334
+ await fs.writeFile(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
335
+ await expect(git(alice, ['push', '-q', 'origin', 'alice/a'])).rejects.toThrow();
336
+
337
+ expect((await git(svc.repository.dir, ['rev-parse', 'refs/heads/alice/a'])).trim()).toBe(commit);
338
+ await expect(git(checkoutOf('bob/b'), ['merge-base', '--is-ancestor', commit, 'HEAD'])).rejects.toThrow();
339
+
340
+ await fs.rm(hook);
341
+ await git(alice, ['push', '-q', 'origin', 'alice/a']);
342
+ expect((await git(upstream, ['rev-parse', 'alice/a'])).trim()).toBe(commit);
343
+ });
344
+ });
345
+
346
+ describe('the repository address changes', () => {
347
+ it('sets the repository and all its worktrees aside together, and the new address gets a fresh repository', async () => {
348
+ const svc = service();
349
+ await svc.getOrCreateForBranch('alice/a');
350
+ await svc.getOrCreateForBranch('bob/b');
351
+ const alice = checkoutOf('alice/a');
352
+ await fs.writeFile(path.join(alice, 'alice.md'), 'never pushed\n');
353
+ await git(alice, ['commit', '-qam', 'never pushed']);
354
+ const unpushed = (await git(alice, ['rev-parse', 'HEAD'])).trim();
355
+
356
+ // Another repository, with no history in common.
357
+ const other = path.join(root, 'other.git');
358
+ await git(root, ['init', '--bare', '-b', 'main', other]);
359
+ const otherSeed = path.join(root, '.other-seed');
360
+ await fs.mkdir(otherSeed);
361
+ await git(otherSeed, ['init', '-b', 'alice/a']);
362
+ await fs.writeFile(path.join(otherSeed, 'other.md'), 'other\n');
363
+ await git(otherSeed, ['add', '-A']);
364
+ await git(otherSeed, ['commit', '-m', 'other']);
365
+ await git(otherSeed, ['push', '-q', other, 'alice/a']);
366
+
367
+ const setAside = path.join(root, 'set-aside');
368
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
369
+ const moved = service(new NodeGitRunner(), other, setAside);
370
+ await moved.getOrCreateForBranch('alice/a');
371
+
372
+ const [stamp] = await fs.readdir(setAside);
373
+ const kept = path.join(setAside, stamp!);
374
+ expect((await fs.readdir(kept)).sort()).toEqual(
375
+ ['knowledge-base.git', workspaceIdForBranch('alice/a'), workspaceIdForBranch('bob/b')].sort(),
376
+ );
377
+ // Nothing deleted: the unpushed commit reads in the set-aside worktree,
378
+ // which is linked to the set-aside repository, both ways.
379
+ const keptAlice = path.join(kept, workspaceIdForBranch('alice/a'));
380
+ expect((await git(keptAlice, ['rev-parse', 'HEAD'])).trim()).toBe(unpushed);
381
+ expect(asGit((await git(keptAlice, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim())).toBe(
382
+ asGit(await fs.realpath(path.join(kept, 'knowledge-base.git'))),
383
+ );
384
+ const keptList = asGit(await git(path.join(kept, 'knowledge-base.git'), ['worktree', 'list', '--porcelain']));
385
+ expect(keptList).toContain(asGit(await fs.realpath(keptAlice)));
386
+ expect(keptList).not.toContain('prunable');
387
+ // The new address has its own repository and worktree.
388
+ expect(await fs.readFile(path.join(alice, 'other.md'), 'utf-8')).toBe('other\n');
389
+ expect((await git(moved.repository.dir, ['config', '--get', 'remote.origin.url'])).trim()).toBe(other);
390
+ });
391
+ });
392
+
393
+ describe('the repository address changes, with nothing of the branch on disk yet', () => {
394
+ it('sets the repository aside before adding a worktree to it, rather than fetching from the old address', async () => {
395
+ await service().getOrCreateForBranch('alice/a');
396
+ const other = path.join(root, 'other.git');
397
+ await git(root, ['init', '--bare', '-b', 'main', other]);
398
+ const otherSeed = path.join(root, '.other-seed');
399
+ await fs.mkdir(otherSeed);
400
+ await git(otherSeed, ['init', '-b', 'carol/c']);
401
+ await fs.writeFile(path.join(otherSeed, 'other.md'), 'other\n');
402
+ await git(otherSeed, ['add', '-A']);
403
+ await git(otherSeed, ['commit', '-m', 'other']);
404
+ await git(otherSeed, ['push', '-q', other, 'carol/c']);
405
+
406
+ const setAside = path.join(root, 'set-aside');
407
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
408
+ const moved = service(new NodeGitRunner(), other, setAside);
409
+ await moved.getOrCreateForBranch('carol/c');
410
+
411
+ const [stamp] = await fs.readdir(setAside);
412
+ expect((await fs.readdir(path.join(setAside, stamp!))).sort()).toEqual(
413
+ ['knowledge-base.git', workspaceIdForBranch('alice/a')].sort(),
414
+ );
415
+ expect(await fs.readFile(path.join(checkoutOf('carol/c'), 'other.md'), 'utf-8')).toBe('other\n');
416
+ expect((await git(moved.repository.dir, ['config', '--get', 'remote.origin.url'])).trim()).toBe(other);
417
+ });
418
+
419
+ it('sets it aside once when two absent branches are opened at once, and both get worktrees of the new one', async () => {
420
+ await service().getOrCreateForBranch('alice/a');
421
+ const other = path.join(root, 'other.git');
422
+ await git(root, ['init', '--bare', '-b', 'main', other]);
423
+ const otherSeed = path.join(root, '.other-seed');
424
+ await fs.mkdir(otherSeed);
425
+ await git(otherSeed, ['init', '-b', 'carol/c']);
426
+ await fs.writeFile(path.join(otherSeed, 'other.md'), 'other\n');
427
+ await git(otherSeed, ['add', '-A']);
428
+ await git(otherSeed, ['commit', '-m', 'other']);
429
+ await git(otherSeed, ['push', '-q', other, 'carol/c', 'carol/c:main']);
430
+
431
+ const setAside = path.join(root, 'set-aside');
432
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
433
+ const moved = service(new NodeGitRunner(), other, setAside);
434
+ await Promise.all([moved.getOrCreateForBranch('carol/c'), moved.getOrCreateForBranch('main')]);
435
+
436
+ // One repository set aside, with the one worktree it had; neither new
437
+ // worktree went with a second set-aside.
438
+ const kept: string[] = [];
439
+ for (const stamp of await fs.readdir(setAside)) {
440
+ for (const name of await fs.readdir(path.join(setAside, stamp))) kept.push(name);
441
+ }
442
+ expect(kept.sort()).toEqual(['knowledge-base.git', workspaceIdForBranch('alice/a')].sort());
443
+ for (const branch of ['carol/c', 'main']) {
444
+ expect(await workingCopyShape(checkoutOf(branch))).toBe('worktree');
445
+ expect(await fs.readFile(path.join(checkoutOf(branch), 'other.md'), 'utf-8')).toBe('other\n');
446
+ }
447
+ expect((await git(moved.repository.dir, ['config', '--get', 'remote.origin.url'])).trim()).toBe(other);
448
+ });
449
+ });
450
+
451
+ describe('setting the repository aside', () => {
452
+ it('finds the worktrees when the repository is held by another spelling of its path than git wrote', async () => {
453
+ const svc = service();
454
+ await svc.getOrCreateForBranch('alice/a');
455
+ await svc.getOrCreateForBranch('bob/b');
456
+ // Windows names one folder two ways (`RUNNER~1` and `runneradmin`); a
457
+ // link to the same folder is the portable way to get two spellings.
458
+ const alias = path.join(os.tmpdir(), `bevel-alias-${path.basename(root)}`);
459
+ await fs.symlink(root, alias, 'junction');
460
+ try {
461
+ const aliased = new KbRepository({
462
+ dir: path.join(alias, path.relative(root, svc.repository.dir)),
463
+ kbRepoUrl: () => upstream,
464
+ gitRunner: new NodeGitRunner(),
465
+ });
466
+ // The destination too is named through the alias.
467
+ const dest = path.join(alias, 'set-aside', 'stamp');
468
+
469
+ const moved = await setAsideRepository(aliased, workspacesRoot, 'knowledge-base', dest);
470
+
471
+ expect(moved.sort()).toEqual([workspaceIdForBranch('alice/a'), workspaceIdForBranch('bob/b')].sort());
472
+ expect((await fs.readdir(dest)).sort()).toEqual(
473
+ ['knowledge-base.git', workspaceIdForBranch('alice/a'), workspaceIdForBranch('bob/b')].sort(),
474
+ );
475
+ // Re-linked by the names the file system gives them, as git writes its
476
+ // own links: `git worktree list` shows the real path, not the alias.
477
+ const realDest = await fs.realpath(dest);
478
+ const list = asGit(await git(path.join(dest, 'knowledge-base.git'), ['worktree', 'list', '--porcelain']));
479
+ for (const id of [workspaceIdForBranch('alice/a'), workspaceIdForBranch('bob/b')]) {
480
+ expect(list).toContain(`worktree ${asGit(path.join(realDest, id))}\n`);
481
+ }
482
+ expect(list).not.toContain('prunable');
483
+ } finally {
484
+ // The link only, never through it.
485
+ await fs.unlink(alias).catch(() => fs.rmdir(alias));
486
+ }
487
+ });
488
+
489
+ it('moves nothing when the reason for it no longer holds once it is the administrator', async () => {
490
+ const svc = service();
491
+ await svc.getOrCreateForBranch('alice/a');
492
+ const dest = path.join(root, 'set-aside', 'stamp');
493
+
494
+ expect(await setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest, async () => false)).toEqual([]);
495
+ expect(await fs.access(dest).then(() => true, () => false)).toBe(false);
496
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('worktree');
497
+ expect(await svc.repository.exists()).toBe(true);
498
+ });
499
+
500
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
501
+ 'moves nothing when the workspaces root cannot be read, rather than the repository without its worktrees',
502
+ async () => {
503
+ const svc = service();
504
+ await svc.getOrCreateForBranch('alice/a');
505
+ const dest = path.join(root, 'set-aside', 'stamp');
506
+ await fs.chmod(workspacesRoot, 0o000);
507
+ try {
508
+ await expect(setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest)).rejects.toThrow(
509
+ /EACCES/,
510
+ );
511
+ } finally {
512
+ await fs.chmod(workspacesRoot, 0o755);
513
+ }
514
+ expect(await svc.repository.exists()).toBe(true);
515
+ expect(await fs.access(dest).then(() => true, () => false)).toBe(false);
516
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('worktree');
517
+ },
518
+ );
519
+
520
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
521
+ "moves nothing when a working copy's .git cannot be read, rather than the repository without that worktree",
522
+ async () => {
523
+ const svc = service();
524
+ await svc.getOrCreateForBranch('alice/a');
525
+ await svc.getOrCreateForBranch('bob/b');
526
+ const link = path.join(checkoutOf('alice/a'), '.git');
527
+ const dest = path.join(root, 'set-aside', 'stamp');
528
+ await fs.chmod(link, 0o000);
529
+ try {
530
+ await expect(setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest)).rejects.toThrow(
531
+ /EACCES/,
532
+ );
533
+ } finally {
534
+ await fs.chmod(link, 0o644);
535
+ }
536
+ expect(await fs.access(dest).then(() => true, () => false)).toBe(false);
537
+ expect(await svc.repository.exists()).toBe(true);
538
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('worktree');
539
+ expect(await workingCopyShape(checkoutOf('bob/b'))).toBe('worktree');
540
+ },
541
+ );
542
+
543
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
544
+ "moves nothing when a worktree's record in the repository cannot be read, rather than guessing it is not one",
545
+ async () => {
546
+ const svc = service();
547
+ await svc.getOrCreateForBranch('alice/a');
548
+ const records = path.join(svc.repository.dir, 'worktrees');
549
+ const dest = path.join(root, 'set-aside', 'stamp');
550
+ await fs.chmod(records, 0o000);
551
+ try {
552
+ await expect(setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest)).rejects.toThrow(
553
+ /EACCES/,
554
+ );
555
+ } finally {
556
+ await fs.chmod(records, 0o755);
557
+ }
558
+ expect(await fs.access(dest).then(() => true, () => false)).toBe(false);
559
+ expect(await svc.repository.exists()).toBe(true);
560
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('worktree');
561
+ },
562
+ );
563
+
564
+ it('leaves both links of a worktree as they were when only one of them can be re-written', async () => {
565
+ const svc = service();
566
+ await svc.getOrCreateForBranch('alice/a');
567
+ await svc.getOrCreateForBranch('bob/b');
568
+ const aliceGitfile = path.join(checkoutOf('alice/a'), '.git');
569
+ const before = await fs.readFile(aliceGitfile, 'utf8');
570
+ // The record behind alice's worktree cannot be written: its `gitdir` is a folder.
571
+ const admin = (await git(checkoutOf('alice/a'), ['rev-parse', '--path-format=absolute', '--git-dir'])).trim();
572
+ await fs.rm(path.join(admin, 'gitdir'));
573
+ await fs.mkdir(path.join(admin, 'gitdir'));
574
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
575
+ vi.spyOn(console, 'error').mockImplementation(() => {});
576
+ const dest = path.join(root, 'set-aside', 'stamp');
577
+
578
+ await setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest);
579
+
580
+ // Not half re-pointed: the moved `.git` still names what it named.
581
+ expect(await fs.readFile(path.join(dest, workspaceIdForBranch('alice/a'), '.git'), 'utf8')).toBe(before);
582
+ // The other worktree is re-linked both ways.
583
+ const keptBob = path.join(dest, workspaceIdForBranch('bob/b'));
584
+ expect(asGit((await git(keptBob, ['rev-parse', '--path-format=absolute', '--git-common-dir'])).trim())).toBe(
585
+ asGit(await fs.realpath(path.join(dest, 'knowledge-base.git'))),
586
+ );
587
+ });
588
+
589
+ it('puts the worktrees back when the repository cannot follow them, so nothing is split', async () => {
590
+ const svc = service();
591
+ await svc.getOrCreateForBranch('alice/a');
592
+ await svc.getOrCreateForBranch('bob/b');
593
+ const dest = path.join(root, 'set-aside', 'stamp');
594
+ // Somebody's earlier set-aside already holds the repository's name there.
595
+ await fs.mkdir(path.join(dest, 'knowledge-base.git'), { recursive: true });
596
+
597
+ await expect(setAsideRepository(svc.repository, workspacesRoot, 'knowledge-base', dest)).rejects.toThrow(
598
+ /already holds one/,
599
+ );
600
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('worktree');
601
+ expect(await workingCopyShape(checkoutOf('bob/b'))).toBe('worktree');
602
+ expect((await fs.readdir(dest)).sort()).toEqual(['knowledge-base.git']);
603
+ expect((await git(checkoutOf('bob/b'), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('bob/b');
604
+ });
605
+ });
606
+
607
+ describe('a deployment of full clones from an earlier release', () => {
608
+ it('is served as it is until the boot phase converts it', async () => {
609
+ await fs.mkdir(path.join(workspacesRoot, workspaceIdForBranch('alice/a')), { recursive: true });
610
+ await git(root, ['clone', '-q', '-b', 'alice/a', upstream, checkoutOf('alice/a')]);
611
+ const svc = service();
612
+ await svc.getOrCreateForBranch('alice/a');
613
+ await svc.getOrCreateForBranch('bob/b');
614
+
615
+ expect(await workingCopyShape(checkoutOf('alice/a'))).toBe('clone');
616
+ expect(await workingCopyShape(checkoutOf('bob/b'))).toBe('worktree');
617
+ expect(await svc.readFile(workspaceIdForBranch('alice/a'), 'knowledge-base/alice.md')).toBe('alice/a\n');
618
+ await svc.ensureRemotesFetched(workspaceIdForBranch('alice/a'), { strict: true });
619
+ });
620
+ });
@@ -1,5 +1,5 @@
1
1
  import express from 'express';
2
- import { afterEach, describe, expect, it } from 'vitest';
2
+ import { afterEach, describe, expect, it, afterAll } from 'vitest';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
@@ -16,6 +16,11 @@ import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
16
16
  import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
17
17
  import { registerWorkspaceTools } from '../workspace.tools.js';
18
18
  import { sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
19
+ import { WorkerPatternMatcher } from '../bounded-matcher.js';
20
+
21
+ /** The grep matcher this file's harness wires, closed once the file is done. */
22
+ const patternMatcher = new WorkerPatternMatcher();
23
+ afterAll(() => patternMatcher.close());
19
24
 
20
25
  /**
21
26
  * What the platform TELLS AN AGENT about the guide.
@@ -52,6 +57,15 @@ async function descriptions(registry: ToolRegistry = new ToolRegistry()): Promis
52
57
  },
53
58
  new RoutineWritePolicyService(),
54
59
  {} as never,
60
+ // Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
61
+ undefined,
62
+ undefined,
63
+ undefined,
64
+ undefined,
65
+ undefined,
66
+ undefined,
67
+ undefined,
68
+ patternMatcher,
55
69
  );
56
70
  return listed(registry);
57
71
  }