@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,29 @@
1
+ /**
2
+ * The entry other modules reach auth through: its admission contract, and
3
+ * the two things a module checking a credential or refusing a switched-off
4
+ * account needs to agree with auth on. The composition root wires the rest.
5
+ *
6
+ * The embed reaches auth here. Modules written before this entry existed
7
+ * still import auth's files directly (`auth.service.ts`, the middleware's
8
+ * request augmentation, `account-admission.ts`); each moves here as it is
9
+ * touched, and what it needs is added here then — this file holds what is
10
+ * offered, not everything auth has.
11
+ */
12
+ export {
13
+ ACCOUNT_DEACTIVATED_MESSAGE,
14
+ AccountAdmissionRefusedError,
15
+ AccountChangeRefusedError,
16
+ AccountDeactivatedError,
17
+ AuthBackendError,
18
+ admitEveryone,
19
+ type AccountAdmissionVerdict,
20
+ type AccountProvisionReason,
21
+ type IAccountAdmission,
22
+ } from './account-admission.js';
23
+ // A constant-time comparison for a secret arriving on a request, so a
24
+ // shared-secret check elsewhere cannot leak the secret's length or prefix by
25
+ // timing — one implementation, auth's.
26
+ export { timingSafeStringEqual } from './password-hash.js';
27
+ // The Express `Request` augmentation (`req.userId`, …): a module whose routes
28
+ // read it imports this entry, never the middleware file.
29
+ import './auth.middleware.js';
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
8
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
9
  import { DiffService } from '../diff.service.js';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  /**
13
14
  * Atomic-seed regression suite.
@@ -53,7 +54,7 @@ async function setup(): Promise<Fixture> {
53
54
  const workspaceDir = path.join(workspacesRoot, workspaceId);
54
55
  const repoDir = path.join(workspaceDir, 'knowledge-base');
55
56
  await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
56
- const workspaceService = new WorkspaceService(
57
+ const workspaceService = newWorkspaceService(
57
58
  workspacesRoot,
58
59
  'https://github.com/Bevel-Software/knowledge-base.git',
59
60
  testKbContext(),
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
8
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
9
  import { DiffService } from '../diff.service.js';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  async function mkTmpRoot(prefix: string): Promise<string> {
13
14
  return fs.mkdtemp(path.join(os.tmpdir(), prefix));
@@ -39,7 +40,7 @@ async function setup(): Promise<Fixture> {
39
40
  const repoDir = path.join(workspaceDir, 'knowledge-base');
40
41
  // Inner `.git` dir is the marker `resolveWorkspaceDir` looks for.
41
42
  await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
42
- const workspaceService = new WorkspaceService(
43
+ const workspaceService = newWorkspaceService(
43
44
  workspacesRoot,
44
45
  'https://github.com/Bevel-Software/knowledge-base.git',
45
46
  testKbContext(),
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { NodeFs } from '../../kb-fs/node-fs.js';
8
+ import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
+ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
10
+ import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { DiffService } from '../diff.service.js';
12
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
13
+
14
+ /**
15
+ * The review session names the branch the workspace is on, read from HEAD
16
+ * without spawning git. In a worktree `.git` is a FILE naming the worktree's
17
+ * folder in the repository, where its HEAD lives.
18
+ */
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ async function git(cwd: string, args: string[]): Promise<void> {
23
+ await execFileAsync('git', args, {
24
+ cwd,
25
+ env: {
26
+ ...process.env,
27
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
28
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
29
+ },
30
+ });
31
+ }
32
+
33
+ let root: string;
34
+
35
+ beforeEach(async () => {
36
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-diff-worktree-'));
37
+ });
38
+ afterEach(async () => {
39
+ await fs.rm(root, { recursive: true, force: true });
40
+ });
41
+
42
+ describe('DiffService on a worktree', () => {
43
+ it("names the worktree's own branch in the review session", async () => {
44
+ const upstream = path.join(root, 'upstream.git');
45
+ await git(root, ['init', '--bare', '-b', 'main', upstream]);
46
+ const seed = path.join(root, '.seed');
47
+ await fs.mkdir(seed);
48
+ await git(seed, ['init', '-b', 'alice/draft']);
49
+ await fs.writeFile(path.join(seed, 'page.md'), 'before\n');
50
+ await git(seed, ['add', '-A']);
51
+ await git(seed, ['commit', '-m', 'init']);
52
+ await git(seed, ['push', '-q', upstream, 'alice/draft']);
53
+
54
+ const workspacesRoot = path.join(root, 'workspaces');
55
+ const workspaces = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
56
+ const diff = new DiffService(
57
+ workspaces,
58
+ new WorkspaceMutex(),
59
+ workspacesRoot,
60
+ path.join(root, 'backups'),
61
+ 'knowledge-base',
62
+ new NodeFs(),
63
+ );
64
+ workspaces.setDiffService(diff);
65
+ const workspaceId = workspaceIdForBranch('alice/draft');
66
+ const info = await workspaces.getOrCreateForBranch('alice/draft');
67
+ expect((await fs.stat(path.join(info.absolutePath, 'knowledge-base', '.git'))).isFile()).toBe(true);
68
+
69
+ // The first look seeds the ledger; a change made after it is pending.
70
+ expect(await diff.currentSession(workspaceId)).toBeNull();
71
+ await fs.writeFile(path.join(info.absolutePath, 'knowledge-base', 'page.md'), 'after\n');
72
+
73
+ const session = await diff.currentSession(workspaceId);
74
+ expect(session?.branchName).toBe('alice/draft');
75
+ });
76
+ });
@@ -4,6 +4,7 @@ import type { FileDiffPayload, PendingChange, ReviewSession, ChangeKind } from '
4
4
  import type { IDiffService } from './diff.interface.js';
5
5
  import type { WorkspaceService } from '../workspace/workspace.service.js';
6
6
  import type { WorkspaceMutex } from '../kb-fs/mutex.js';
7
+ import { gitDirOf } from '../kb-fs/index.js';
7
8
  import { isAbsence, type ITreeWalker, type TreeWalkOptions } from '../../shared/fs.contract.js';
8
9
  import { isDiffable } from './diff.config.js';
9
10
  import { assertWithinDirectory } from '../../shared/path-containment.js';
@@ -385,10 +386,11 @@ export class DiffService implements IDiffService {
385
386
  private async tryReadCurrentBranch(workspaceId: string): Promise<string> {
386
387
  const workspaceDir = await this.workspaceService.getWorkspacePath(workspaceId);
387
388
  try {
388
- const head = await fs.readFile(
389
- path.join(workspaceDir, this.kbDirName, '.git', 'HEAD'),
390
- 'utf-8',
391
- );
389
+ // A worktree's `.git` is a FILE naming its folder in the repository,
390
+ // where its HEAD lives; a full clone's is the folder itself.
391
+ const gitDir = await gitDirOf(path.join(workspaceDir, this.kbDirName));
392
+ if (gitDir === null) return '';
393
+ const head = await fs.readFile(path.join(gitDir, 'HEAD'), 'utf-8');
392
394
  const m = head.match(/^ref:\s+refs\/heads\/(.+?)\s*$/);
393
395
  return m ? m[1] : '';
394
396
  } catch {
@@ -168,15 +168,51 @@ describe('EmbedService: minting', () => {
168
168
  /**
169
169
  * The token rides in the tool result and so in the chat transcript;
170
170
  * whoever holds it acts on that one file as its user until it expires.
171
- * One hour (Razvan, 2026-10-09), down from the two the embed used before.
171
+ * Five minutes (Razvan, 2026-10-09): the chat view renews it through its
172
+ * host when a call is refused, so a short life costs the reader nothing.
172
173
  */
173
- it('mints a token that lives one hour', async () => {
174
+ it('mints an open_page token that lives five minutes', async () => {
174
175
  const { service } = build();
175
176
  const { token } = await service.mintForUser({ userId: USER.id, reference: REPO });
176
177
  const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
178
+ expect(exp - iat).toBe(5 * 60);
179
+ });
180
+
181
+ /** The connector frames `/embed` and has no host to renew through: its hour stands. */
182
+ it('mints a connector token that keeps its one-hour lifetime', async () => {
183
+ const { service } = build();
184
+ const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
185
+ const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
177
186
  expect(exp - iat).toBe(60 * 60);
178
187
  });
179
188
 
189
+ /**
190
+ * Both kinds are read by the same verification: each loads while it lives
191
+ * and is refused as a token once it has run out — the user's after five
192
+ * minutes, the connector's only after the hour.
193
+ */
194
+ it('reads both kinds while they live and refuses each once its own lifetime is over', async () => {
195
+ vi.useFakeTimers({ toFake: ['Date'] });
196
+ try {
197
+ vi.setSystemTime(new Date('2026-10-09T10:00:00Z'));
198
+ const { service } = build();
199
+ const user = await service.mintForUser({ userId: USER.id, reference: REPO });
200
+ const connector = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
201
+
202
+ vi.setSystemTime(new Date('2026-10-09T10:04:50Z'));
203
+ await expect(service.loadFile(user.token)).resolves.toMatchObject({ repoRelative: REPO });
204
+
205
+ vi.setSystemTime(new Date('2026-10-09T10:05:01Z'));
206
+ await expect(service.loadFile(user.token)).rejects.toThrow(EmbedTokenError);
207
+ await expect(service.loadFile(connector.token)).resolves.toMatchObject({ repoRelative: REPO });
208
+
209
+ vi.setSystemTime(new Date('2026-10-09T11:00:01Z'));
210
+ await expect(service.loadFile(connector.token)).rejects.toThrow(EmbedTokenError);
211
+ } finally {
212
+ vi.useRealTimers();
213
+ }
214
+ });
215
+
180
216
  it('mints for an outside account — the connector path, unchanged', async () => {
181
217
  const { service } = build();
182
218
  const { token } = await service.mintToken({
@@ -189,7 +225,9 @@ describe('EmbedService: minting', () => {
189
225
 
190
226
  it('keeps the token PSEUDONYMOUS — no address, no name', async () => {
191
227
  const { service } = build();
192
- const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
228
+ const { token, embedUrl } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
229
+ // The connector's host sizes its frame to the content: its view is told so.
230
+ expect(embedUrl).toBe(`https://hexis.example/embed?token=${encodeURIComponent(token)}&sizing=content`);
193
231
  expect(JSON.stringify(claimsOf(token))).not.toContain(USER.email);
194
232
  expect(JSON.stringify(claimsOf(token))).not.toContain(USER.name);
195
233
  });
@@ -711,6 +749,41 @@ describe('EmbedService: a switched-off account', () => {
711
749
  });
712
750
  });
713
751
 
752
+ /**
753
+ * A distribution's own token-authed route beside the embed (a graph for a
754
+ * dashboard) asks who is looking before it serves anything, and must judge
755
+ * the viewer exactly as the embed does.
756
+ */
757
+ describe('EmbedService: who is looking through a token', () => {
758
+ it('answers the linked user, their verdicts on the file, and the file', async () => {
759
+ const { service } = build({ canWrite: false });
760
+ const { token } = await service.mintForUser({ userId: USER.id, reference: `${REPO}#intro` });
761
+ expect(await service.viewerOf(token)).toEqual({
762
+ linked: true,
763
+ user: expect.objectContaining({ id: USER.id, email: USER.email }),
764
+ canRead: true,
765
+ canWrite: false,
766
+ repoRelative: REPO,
767
+ });
768
+ });
769
+
770
+ it('answers nobody for an unlinked outside account, and refuses a switched-off one as the embed does', async () => {
771
+ const unlinked = build({ linkedUserId: null });
772
+ const outside = await unlinked.service.mintToken({ accountId: 'acc-1', reference: REPO });
773
+ expect(await unlinked.service.viewerOf(outside.token)).toEqual({
774
+ linked: false,
775
+ user: null,
776
+ canRead: false,
777
+ canWrite: false,
778
+ repoRelative: REPO,
779
+ });
780
+ const off = build({ active: false });
781
+ const { token } = await off.service.mintForUser({ userId: USER.id, reference: REPO });
782
+ await expect(off.service.viewerOf(token)).rejects.toThrow(ACCOUNT_DEACTIVATED_MESSAGE);
783
+ await expect(off.service.viewerOf('not-a-token')).rejects.toThrow(EmbedTokenError);
784
+ });
785
+ });
786
+
714
787
  describe('EmbedService: the roles.yaml write gate', () => {
715
788
  const ROLES = `${KB}/roles.yaml`;
716
789
  const VALID = 'roles:\n Admin:\n - a@x.eu\n';
@@ -2,6 +2,7 @@ import type { Server } from 'node:http';
2
2
  import type { AddressInfo } from 'node:net';
3
3
  import express from 'express';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { checkFor } from '@bevel-software/platform-mcp-core';
5
6
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
6
7
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
7
8
  import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
@@ -122,6 +123,21 @@ describe('open_page: the listing', () => {
122
123
  expect(internal).not.toContain(OPEN_PAGE_TOOL);
123
124
  });
124
125
 
126
+ /**
127
+ * The chat view renews its token by having its host call this tool with
128
+ * `{ body: { path, heading } }` (see `mcp-app/page.html`). The listing's
129
+ * own check decides what `/api/mcp` accepts: that shape passes it, and the
130
+ * flat one the view once sent is refused, so no token ever came back.
131
+ */
132
+ it("accepts the chat view's renewal arguments under body, and refuses them flat", async () => {
133
+ const { registry } = await serve();
134
+ const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
135
+ const compiled = checkFor(def.inputs);
136
+ if (!compiled.checkable) throw new Error(`open_page's schema is not checkable: ${compiled.reason}`);
137
+ expect(compiled.check({ body: { path: 'Data/Thing.md', heading: 'what-it-is' } })).toEqual([]);
138
+ expect(compiled.check({ path: 'Data/Thing.md', heading: 'what-it-is' })).not.toEqual([]);
139
+ });
140
+
125
141
  it('takes a path and an optional heading, and no branch at all', async () => {
126
142
  const { registry } = await serve();
127
143
  const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
@@ -197,6 +213,19 @@ describe('open_page: the answer', () => {
197
213
  expect(body.appUrl).toContain('#what-it-is');
198
214
  });
199
215
 
216
+ /**
217
+ * The view renews its token by calling this tool again with the arguments
218
+ * it was opened with, and the result is all it is told — so the heading
219
+ * comes back beside the path, and is absent when none was named.
220
+ */
221
+ it('echoes the heading beside the path, for the view to call again with', async () => {
222
+ const { call } = await serve();
223
+ const { body } = await call({ path: 'Data/Thing.md', heading: 'what-it-is' });
224
+ expect(body).toMatchObject({ path: 'Data/Thing.md', heading: 'what-it-is' });
225
+ const plain = await call({ path: 'Data/Thing.md' });
226
+ expect(plain.body).not.toHaveProperty('heading');
227
+ });
228
+
200
229
  it('accepts a path with or without the knowledge-base prefix, as read_file documents', async () => {
201
230
  const { call, mints } = await serve();
202
231
  await call({ path: 'Data/Thing.md' });
@@ -103,6 +103,19 @@ export interface EmbedLinkedAccount {
103
103
  */
104
104
  export type EmbedNodeIdResolver = (nodeId: string) => Promise<string | null>;
105
105
 
106
+ /** The viewer behind a token, as {@link IEmbedService.viewerOf} answers it. */
107
+ export interface EmbedViewer {
108
+ /** Whether the token's identity resolved to a user at all. */
109
+ linked: boolean;
110
+ /** The linked user; null when unlinked. */
111
+ user: AuthUser | null;
112
+ /** Read access on the token's file — always false when unlinked. */
113
+ canRead: boolean;
114
+ canWrite: boolean;
115
+ /** The token's file, repository-relative. */
116
+ repoRelative: string;
117
+ }
118
+
106
119
  /**
107
120
  * What the embed service needs of the workspace: the clone of a branch, and
108
121
  * four verbs on one file of it. A port rather than the workspace service
@@ -165,6 +178,17 @@ export interface IEmbedService {
165
178
  /** Load the file the token is scoped to, with the viewer's read/write status. */
166
179
  loadFile(token: string): Promise<EmbedFileView>;
167
180
 
181
+ /**
182
+ * Who is looking through `token`, and at what: the resolved viewer (the
183
+ * linked user, or none), their read and write verdicts on the token's
184
+ * file, and the file. The one answer a distribution's own token-authed
185
+ * route beside this surface (a graph for a dashboard, say) asks before it
186
+ * serves anything, so it judges the viewer exactly as the embed does — the
187
+ * same token check, the same account link, the same switched-off refusal.
188
+ * Throws as `loadFile` throws for a token that is invalid or expired.
189
+ */
190
+ viewerOf(token: string): Promise<EmbedViewer>;
191
+
168
192
  /**
169
193
  * The raw bytes of the embedded file, or of one asset `path` beside it (an
170
194
  * image a markdown page shows — a file no reader edits as text, or an SVG,
@@ -1,6 +1,5 @@
1
1
  import { createHmac } from 'node:crypto';
2
- import { timingSafeStringEqual } from '../auth/password-hash.js';
3
- import { ACCOUNT_DEACTIVATED_MESSAGE } from '../auth/account-admission.js';
2
+ import { ACCOUNT_DEACTIVATED_MESSAGE, timingSafeStringEqual } from '../auth/index.js';
4
3
  import jwt from 'jsonwebtoken';
5
4
  import type { AuthUser, IGitService, IWorkflowService } from '@bevel-software/platform-shared';
6
5
  import { suggestionsBranchPrefixFor } from '@bevel-software/platform-shared';
@@ -8,9 +7,9 @@ import { logger } from '../../shared/logging.js';
8
7
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
9
8
  import type { KbContext } from '../../shared/kb-context.js';
10
9
  import type { IAccessControl } from '../access/access-control.interface.js';
11
- import { changeRequestLink, changeRequestLinkBase } from '../workflow/git/change-request-link.js';
12
- import type { FileReaderRegistry } from '../workspace/file-readers/file-reader.js';
13
- import type { WriteValidator } from '../kb-fs/locking-filesystem.js';
10
+ import { changeRequestLink, changeRequestLinkBase } from '../workflow/index.js';
11
+ import type { FileReaderRegistry } from '../workspace/index.js';
12
+ import type { WriteValidator } from '../kb-fs/index.js';
14
13
  import type {
15
14
  EmbedAuthPort,
16
15
  EmbedFileView,
@@ -21,6 +20,7 @@ import type {
21
20
  EmbedProposalResult,
22
21
  EmbedSubject,
23
22
  EmbedTokenResult,
23
+ EmbedViewer,
24
24
  IEmbedService,
25
25
  } from './embed.interface.js';
26
26
  import {
@@ -35,16 +35,24 @@ import type { IAccountLinkService } from './account-link.service.js';
35
35
  const log = logger('embed');
36
36
 
37
37
  /**
38
- * Token lifetime — long enough for a view left open in a chat, short enough
39
- * that a token copied out of a transcript stops working on its own. The
40
- * token rides in `open_page`'s result, so it sits in the chat transcript, and
41
- * whoever holds it reads and edits that one file as the token's user until
42
- * it expires. One hour (Razvan, 2026-10-09; the embed used two before): a
43
- * page left open in a chat is still covered, and the window is halved. A
44
- * view older than that shows the expired sentence and the agent opens the
45
- * page again.
38
+ * Lifetime of a token `open_page` mints (`kind: user`). The token rides in
39
+ * the tool's result, so it sits in the chat transcript, and whoever holds it
40
+ * reads and edits that one file as the token's user until it expires. Five
41
+ * minutes (Razvan, 2026-10-09; one hour before, two before that): a token
42
+ * copied out of a transcript stops working almost at once. A view left open
43
+ * longer is not ended by it — the MCP App view asks its host to call
44
+ * `open_page` again when a call is refused for the token, and carries on with
45
+ * the fresh one (see `mcp-app/page.html`).
46
46
  */
47
- export const EMBED_TOKEN_TTL_SECONDS = 60 * 60;
47
+ export const EMBED_USER_TOKEN_TTL_SECONDS = 5 * 60;
48
+
49
+ /**
50
+ * Lifetime of a token the Atlassian connector mints through the shared-secret
51
+ * route (`kind: atlassian`). One hour, unchanged: the connector frames
52
+ * `/embed` directly and has no host to renew a token through, and its mint
53
+ * contract does not change (Decision 9 of the parent specification).
54
+ */
55
+ export const EMBED_CONNECTOR_TOKEN_TTL_SECONDS = 60 * 60;
48
56
 
49
57
  /** What the embed's config needs from the deployment's. */
50
58
  export interface EmbedConfig {
@@ -148,7 +156,11 @@ export class EmbedService implements IEmbedService {
148
156
  if (!this.authService.isEmailDomainAllowed(email ?? '')) {
149
157
  throw new EmbedAccessError('Your email domain is not permitted to view this content');
150
158
  }
151
- return this.mint({ kind: 'atlassian', accountId: input.accountId }, input.reference);
159
+ const minted = await this.mint({ kind: 'atlassian', accountId: input.accountId }, input.reference);
160
+ // The connector's host sizes its frame to the content (the issue panel
161
+ // grows with the page), so its view is told to report its height; the
162
+ // MCP path says nothing, and its host's fixed reading pane stays.
163
+ return { ...minted, embedUrl: this.embedUrlFor(minted.token, { sizing: 'content' }) };
152
164
  }
153
165
 
154
166
  /**
@@ -195,13 +207,20 @@ export class EmbedService implements IEmbedService {
195
207
  repoRelative,
196
208
  slug: ref.slug,
197
209
  };
198
- const token = jwt.sign(claims, this.signingKey, { expiresIn: EMBED_TOKEN_TTL_SECONDS });
210
+ const expiresIn =
211
+ subject.kind === 'user' ? EMBED_USER_TOKEN_TTL_SECONDS : EMBED_CONNECTOR_TOKEN_TTL_SECONDS;
212
+ const token = jwt.sign(claims, this.signingKey, { expiresIn });
199
213
  return { token, embedUrl: this.embedUrlFor(token) };
200
214
  }
201
215
 
202
- /** The view's address — what a host frames. */
203
- embedUrlFor(token: string): string {
204
- return `${this.config.publicFrontendUrl}/embed?token=${encodeURIComponent(token)}`;
216
+ /**
217
+ * The view's address — what a host frames. `sizing: 'content'` tells the
218
+ * view its host sizes the frame to the content and wants the height
219
+ * reported; absent, the host's fixed pane is kept.
220
+ */
221
+ embedUrlFor(token: string, opts: { sizing?: 'content' } = {}): string {
222
+ const sizing = opts.sizing === 'content' ? '&sizing=content' : '';
223
+ return `${this.config.publicFrontendUrl}/embed?token=${encodeURIComponent(token)}${sizing}`;
205
224
  }
206
225
 
207
226
  /** A file's address in the app — what a link opens in a new tab. */
@@ -556,6 +575,12 @@ export class EmbedService implements IEmbedService {
556
575
  return { claims, user, wsPath: this.wsPathFor(claims.repoRelative) };
557
576
  }
558
577
 
578
+ async viewerOf(token: string): Promise<EmbedViewer> {
579
+ const claims = this.verifyToken(token);
580
+ const identity = await this.resolveIdentity(claims);
581
+ return { ...identity, repoRelative: claims.repoRelative };
582
+ }
583
+
559
584
  /** Resolve the token's identity to a user + read/write verdicts. */
560
585
  private async resolveIdentity(claims: EmbedClaims): Promise<ResolvedIdentity> {
561
586
  const unresolved: ResolvedIdentity = { linked: false, user: null, canRead: false, canWrite: false };
@@ -104,6 +104,10 @@ export function registerEmbedTools(
104
104
  type: 'object',
105
105
  properties: {
106
106
  path: { type: 'string', description: 'The path that was shown (echoes the input).' },
107
+ heading: {
108
+ type: 'string',
109
+ description: 'The heading the view opens at (echoes the input). Absent when none was named.',
110
+ },
107
111
  content: { type: 'string', description: "The page's text, exactly as `read_file` answers it." },
108
112
  embedUrl: {
109
113
  type: 'string',
@@ -163,12 +167,16 @@ export function registerEmbedTools(
163
167
  result.kind === 'text' ? result.text : result.kind === 'image' ? result.note : result.message;
164
168
 
165
169
  const appUrl = deps.appUrlFor(repoRelative, heading);
170
+ // The heading goes back beside the path: the view calls this tool again
171
+ // with both when its token runs out (see `mcp-app/page.html`), and a
172
+ // result is all it is told.
173
+ const echoed = heading ? { heading } : {};
166
174
  // No view on a plain-http deployment (Decision 8): a host's https
167
175
  // sandbox cannot load from it. The text and the app address are still
168
176
  // the answer, and the tool says why there is no view rather than
169
177
  // handing back an address no host can open.
170
178
  if (!deps.canBeReached()) {
171
- return { path: raw, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
179
+ return { path: raw, ...echoed, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
172
180
  }
173
181
  // Minted for the identity THIS MCP session authenticated — the
174
182
  // signed-in user, or the owner of the connection key the agent carries.
@@ -178,7 +186,7 @@ export function registerEmbedTools(
178
186
  userId: ctx.user.id,
179
187
  reference: heading ? `${repoRelative}#${heading}` : repoRelative,
180
188
  });
181
- return { path: raw, content, embedUrl, appUrl, branch };
189
+ return { path: raw, ...echoed, content, embedUrl, appUrl, branch };
182
190
  }),
183
191
  );
184
192
  }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ import { gitDirOf, readGitfile } from '../git-dir.js';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ async function git(cwd: string, ...args: string[]): Promise<string> {
13
+ const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf-8' });
14
+ return stdout.trim();
15
+ }
16
+
17
+ /**
18
+ * The one resolver of a checkout's git directory, checked against what git
19
+ * itself answers (`rev-parse --absolute-git-dir`) for a clone and a worktree.
20
+ */
21
+ describe('gitDirOf', () => {
22
+ let root: string;
23
+
24
+ beforeEach(async () => {
25
+ root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'git-dir-')));
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await fs.rm(root, { recursive: true, force: true });
30
+ });
31
+
32
+ it('is the .git folder of a clone and the folder a worktree .git file names, as git says', async () => {
33
+ const clone = path.join(root, 'clone');
34
+ await fs.mkdir(clone);
35
+ await git(clone, 'init', '-q');
36
+ await git(clone, '-c', 'user.name=T', '-c', 'user.email=t@x.com', 'commit', '-q', '--allow-empty', '-m', 'c');
37
+ const worktree = path.join(root, 'wt');
38
+ await git(clone, 'worktree', 'add', '-q', '-b', 'side', worktree);
39
+
40
+ for (const dir of [clone, worktree]) {
41
+ const fromGit = path.resolve(await git(dir, 'rev-parse', '--absolute-git-dir'));
42
+ expect(path.resolve((await gitDirOf(dir))!)).toBe(await fs.realpath(fromGit));
43
+ }
44
+ });
45
+
46
+ it('is null with no .git, and with a .git file that names no folder', async () => {
47
+ expect(await gitDirOf(root)).toBeNull();
48
+ await fs.writeFile(path.join(root, '.git'), 'not a gitfile\n');
49
+ expect(await gitDirOf(root)).toBeNull();
50
+ });
51
+
52
+ it('reads exactly what git reads: `gitdir: ` and a path, nothing before, after or around it', async () => {
53
+ const gitfile = path.join(root, '.git');
54
+ for (const text of [
55
+ 'not a gitfile\ngitdir: ../elsewhere/wt\n', // a gitdir line further down
56
+ 'gitdir: ../elsewhere/wt\njunk\n', // a second line after the path
57
+ 'gitdir:../elsewhere/wt\n', // no space after the colon
58
+ 'gitdir: ../elsewhere/wt\n', // a path that starts with a space, which exists nowhere
59
+ '\ngitdir: ../elsewhere/wt\n', // a blank first line
60
+ ]) {
61
+ await fs.writeFile(gitfile, text);
62
+ const named = await readGitfile(gitfile);
63
+ // Git refuses each as a gitfile; what this reads of it names no folder
64
+ // git would open.
65
+ await expect(git(root, 'rev-parse', '--absolute-git-dir')).rejects.toThrow();
66
+ expect(named === null || named !== path.resolve(root, '../elsewhere/wt'), JSON.stringify(text)).toBe(true);
67
+ }
68
+ await fs.writeFile(gitfile, 'gitdir: ../elsewhere/wt\r\n');
69
+ expect(await readGitfile(gitfile)).toBe(path.resolve(root, '../elsewhere/wt'));
70
+ });
71
+
72
+ it('resolves a relative gitdir against the file’s own folder', async () => {
73
+ await fs.writeFile(path.join(root, '.git'), 'gitdir: ../elsewhere/wt\n');
74
+ expect(await readGitfile(path.join(root, '.git'))).toBe(path.resolve(root, '../elsewhere/wt'));
75
+ expect(await gitDirOf(root)).toBe(path.resolve(root, '../elsewhere/wt'));
76
+ });
77
+ });