@bevel-software/platform-core-backend 0.29.0 → 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 (181) hide show
  1. package/dist/__tests__/test-repositories.d.ts +3 -0
  2. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  3. package/dist/__tests__/test-repositories.js +18 -0
  4. package/dist/__tests__/test-repositories.js.map +1 -0
  5. package/dist/core/create-core-server.js +1 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +3 -0
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +32 -4
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core/lifecycle.d.ts +8 -0
  12. package/dist/core/lifecycle.d.ts.map +1 -1
  13. package/dist/core/lifecycle.js +4 -0
  14. package/dist/core/lifecycle.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +20 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +32 -1
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +138 -16
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access.routes.d.ts.map +1 -1
  22. package/dist/modules/access/access.routes.js +20 -6
  23. package/dist/modules/access/access.routes.js.map +1 -1
  24. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  25. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  26. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  27. package/dist/modules/diff/diff.service.js +7 -1
  28. package/dist/modules/diff/diff.service.js.map +1 -1
  29. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  30. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  31. package/dist/modules/kb-fs/git-dir.js +58 -0
  32. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  33. package/dist/modules/kb-fs/index.d.ts +2 -0
  34. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  35. package/dist/modules/kb-fs/index.js +2 -1
  36. package/dist/modules/kb-fs/index.js.map +1 -1
  37. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  38. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  39. package/dist/modules/kb-fs/ref-files.js +249 -0
  40. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  41. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  42. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  43. package/dist/modules/plugins/teams.routes.js +9 -1
  44. package/dist/modules/plugins/teams.routes.js.map +1 -1
  45. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  46. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  47. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  48. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  49. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  50. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  51. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  52. package/dist/modules/workflow/git/git.service.d.ts +29 -0
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +177 -24
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  57. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  58. package/dist/modules/workspace/agent-access.gate.js +2 -2
  59. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  60. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  61. package/dist/modules/workspace/bounded-matcher.js +261 -0
  62. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  63. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  64. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  65. package/dist/modules/workspace/fetch-instant.js +13 -0
  66. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  67. package/dist/modules/workspace/index.d.ts +7 -0
  68. package/dist/modules/workspace/index.d.ts.map +1 -1
  69. package/dist/modules/workspace/index.js +6 -0
  70. package/dist/modules/workspace/index.js.map +1 -1
  71. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  72. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  73. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  74. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  75. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  76. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  77. package/dist/modules/workspace/kb-repository.js +811 -0
  78. package/dist/modules/workspace/kb-repository.js.map +1 -0
  79. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  80. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  81. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  82. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  83. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  84. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  85. package/dist/modules/workspace/routine-write-policy.js +0 -5
  86. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  87. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  88. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  89. package/dist/modules/workspace/set-aside-clone.js +139 -0
  90. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  91. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  92. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  93. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  94. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  95. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  96. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  97. package/dist/modules/workspace/workspace.service.js +240 -161
  98. package/dist/modules/workspace/workspace.service.js.map +1 -1
  99. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  100. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  101. package/dist/modules/workspace/workspace.tools.js +162 -336
  102. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  103. package/dist/test-setup.js +4 -0
  104. package/dist/test-setup.js.map +1 -1
  105. package/package.json +3 -3
  106. package/src/__tests__/test-repositories.ts +22 -0
  107. package/src/__tests__/workspace-service.ts +69 -0
  108. package/src/core/__tests__/lifecycle.test.ts +16 -0
  109. package/src/core/create-core-server.ts +1 -1
  110. package/src/core/create-core-services.ts +36 -3
  111. package/src/core/lifecycle.ts +10 -0
  112. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  113. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  114. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  115. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  116. package/src/modules/access/access-control.interface.ts +22 -0
  117. package/src/modules/access/access-control.service.ts +142 -14
  118. package/src/modules/access/access.routes.ts +22 -6
  119. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  120. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  121. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  122. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  123. package/src/modules/diff/diff.service.ts +6 -4
  124. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  125. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  126. package/src/modules/kb-fs/git-dir.ts +53 -0
  127. package/src/modules/kb-fs/index.ts +2 -0
  128. package/src/modules/kb-fs/ref-files.ts +279 -0
  129. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  130. package/src/modules/plugins/teams.routes.ts +14 -1
  131. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  132. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  133. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  134. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  135. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  136. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  137. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  138. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
  139. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  140. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  141. package/src/modules/workflow/git/git.service.ts +184 -22
  142. package/src/modules/workflow/workflow-hooks.ts +1 -1
  143. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  144. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  145. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  146. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  147. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  148. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  149. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  150. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  151. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  152. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  153. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  154. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  155. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  156. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  157. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  158. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  159. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  160. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  161. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  162. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  163. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  164. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  165. package/src/modules/workspace/agent-access.gate.ts +2 -2
  166. package/src/modules/workspace/bounded-matcher.ts +328 -0
  167. package/src/modules/workspace/fetch-instant.ts +13 -0
  168. package/src/modules/workspace/index.ts +12 -0
  169. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  170. package/src/modules/workspace/kb-repository.ts +844 -0
  171. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  172. package/src/modules/workspace/routine-write-policy.ts +0 -15
  173. package/src/modules/workspace/set-aside-clone.ts +140 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  175. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  176. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  177. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  178. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  179. package/src/modules/workspace/workspace.service.ts +247 -169
  180. package/src/modules/workspace/workspace.tools.ts +179 -365
  181. package/src/test-setup.ts +5 -0
@@ -8,6 +8,7 @@ import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.
8
8
  import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
9
9
  import { hashEmail } from '../../shared/email-identity.js';
10
10
  import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
11
+ import { readRevSha } from '../kb-fs/index.js';
11
12
  import type { WorkspaceService } from '../workspace/workspace.service.js';
12
13
  import type {
13
14
  IAccessControl,
@@ -66,6 +67,11 @@ import {
66
67
  DIRECTORY_SYNC_BOT_NAME,
67
68
  } from './directory-sync-bot.js';
68
69
 
70
+ /** A full object id, which is what every at-ref gate resolves its ref to before reading. */
71
+ function isCommitSha(ref: string): boolean {
72
+ return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
73
+ }
74
+
69
75
  /**
70
76
  * What one spec of a batched read came back as: a blob's text, nothing at
71
77
  * that path, or an object that is not a file (a directory, say). The last
@@ -1370,7 +1376,13 @@ function ineligibleNamedEmailsResolved(
1370
1376
  * was built from (not the ref name that led there), so the per-file
1371
1377
  * own-entries reads that follow a gate resolve against exactly that tree.
1372
1378
  */
1373
- type AtRefModel = { model: AccessModel; resolvedRef: string };
1379
+ /**
1380
+ * A model built at a commit, with the commit it was built at and, when the
1381
+ * tree is not too large to hold, every path the commit carries: what the
1382
+ * own-entries reads need to tell a path that is absent at the commit (a
1383
+ * fact, remembered) from a blob the batch could not read (never remembered).
1384
+ */
1385
+ type AtRefModel = { model: AccessModel; resolvedRef: string; tree: ReadonlySet<string> | null };
1374
1386
  /**
1375
1387
  * One build's outcome: a model, no roles.yaml at the commit, or one that
1376
1388
  * doesn't parse. A git read failure is none of these: it throws
@@ -1440,6 +1452,25 @@ export class AccessControlService implements IAccessControl {
1440
1452
  private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
1441
1453
  private static readonly AT_REF_CACHE_MAX = 512;
1442
1454
 
1455
+ /**
1456
+ * A path's own access entries AT A COMMIT, keyed by commit + path. A blob
1457
+ * at a commit never changes, so neither does what it declares for itself:
1458
+ * no TTL, no invalidation, and no workspace in the key (every clone holds
1459
+ * the same objects). This is the read every at-ref gate makes after the
1460
+ * model: one `cat-file --batch` per call for the paths asked, which a
1461
+ * change-request listing made once per ref per listing for the same paths
1462
+ * at the same commits. Bounded FIFO; the bound only caps memory.
1463
+ */
1464
+ private readonly ownEntriesAtCommit = new Map<string, OwnEntries | null>();
1465
+ private static readonly OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
1466
+ /** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
1467
+ private static readonly TREE_INDEX_MAX_PATHS = 20_000;
1468
+ /** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
1469
+ private static readonly TREE_INDEX_BUDGET = 500_000;
1470
+ private readonly treeIndexMaxPaths: number;
1471
+ private readonly treeIndexBudget: number;
1472
+ private treePathsHeld = 0;
1473
+
1443
1474
  /**
1444
1475
  * Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
1445
1476
  * Held on the service rather than read per-model because it comes from the
@@ -1473,10 +1504,19 @@ export class AccessControlService implements IAccessControl {
1473
1504
  * run on the default layout.
1474
1505
  */
1475
1506
  private readonly kb: Pick<KbContext, 'layout'> = { layout: DEFAULT_KB_LAYOUT },
1507
+ /**
1508
+ * How much of the commits' trees the at-ref models may hold (see
1509
+ * `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
1510
+ * deployment's; a suite lowers them to reach the bounds with a small
1511
+ * repository.
1512
+ */
1513
+ limits: { treeIndexMaxPaths?: number; treeIndexBudget?: number } = {},
1476
1514
  ) {
1477
1515
  this.deploymentOwners = new Set(
1478
1516
  deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)),
1479
1517
  );
1518
+ this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
1519
+ this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
1480
1520
  }
1481
1521
 
1482
1522
  /**
@@ -1618,6 +1658,23 @@ export class AccessControlService implements IAccessControl {
1618
1658
  return result;
1619
1659
  }
1620
1660
 
1661
+ async groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]> {
1662
+ const model = await this.loadModel(workspaceId);
1663
+ const email = canonicalEmail(userEmail);
1664
+ const names: string[] = [];
1665
+ for (const principal of model.roles.byCanonical.values()) {
1666
+ if (principal.kind === 'group' && principal.emails.has(email)) names.push(principal.displayName);
1667
+ }
1668
+ return names;
1669
+ }
1670
+
1671
+ async visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]> {
1672
+ if (groups.length === 0) return [];
1673
+ if (await this.holdsAdminRootWrite(workspaceId, userEmail)) return [...groups];
1674
+ const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
1675
+ return groups.filter((g) => mine.has(g));
1676
+ }
1677
+
1621
1678
  async canWriteBatch(
1622
1679
  workspaceId: string,
1623
1680
  userEmail: string,
@@ -2135,7 +2192,7 @@ export class AccessControlService implements IAccessControl {
2135
2192
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2136
2193
  if (!loaded) return null;
2137
2194
  const repoDir = await this.repoDir(workspaceId);
2138
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2195
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2139
2196
  return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
2140
2197
  }
2141
2198
 
@@ -2148,7 +2205,7 @@ export class AccessControlService implements IAccessControl {
2148
2205
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2149
2206
  if (!loaded) return null;
2150
2207
  const repoDir = await this.repoDir(workspaceId);
2151
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2208
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2152
2209
  return canReadResolved(loaded.model, userEmail, relativePath, own);
2153
2210
  }
2154
2211
 
@@ -2171,7 +2228,7 @@ export class AccessControlService implements IAccessControl {
2171
2228
  const repoDir = await this.repoDir(workspaceId);
2172
2229
  // One `git cat-file --batch` for the whole path set — a per-path `git
2173
2230
  // show` spawn made CR owner-routing take minutes on large change sets.
2174
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2231
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2175
2232
  const result = new Map<string, boolean>();
2176
2233
  for (const p of relativePaths) {
2177
2234
  const machineOwned = machineAnswers.get(p);
@@ -2196,7 +2253,7 @@ export class AccessControlService implements IAccessControl {
2196
2253
  if (!loaded) return null;
2197
2254
  const repoDir = await this.repoDir(workspaceId);
2198
2255
  // One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
2199
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2256
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2200
2257
  const result = new Map<string, boolean>();
2201
2258
  for (const p of relativePaths) {
2202
2259
  result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
@@ -2219,7 +2276,7 @@ export class AccessControlService implements IAccessControl {
2219
2276
  const loaded = await this.loadModelAtRef(workspaceId, ref);
2220
2277
  if (!loaded) return null;
2221
2278
  const repoDir = await this.repoDir(workspaceId);
2222
- const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
2279
+ const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
2223
2280
  return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
2224
2281
  }
2225
2282
 
@@ -2333,7 +2390,7 @@ export class AccessControlService implements IAccessControl {
2333
2390
  if (!loaded) return null;
2334
2391
  const repoDir = await this.repoDir(workspaceId);
2335
2392
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
2336
- const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
2393
+ const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
2337
2394
  for (const p of relativePaths) {
2338
2395
  if (result.has(p)) continue;
2339
2396
  const own = owns.get(p) ?? null;
@@ -2551,8 +2608,14 @@ export class AccessControlService implements IAccessControl {
2551
2608
  repoDir: string,
2552
2609
  ref: string,
2553
2610
  relativePath: string,
2611
+ tree: ReadonlySet<string> | null = null,
2554
2612
  ): Promise<OwnEntries | null> {
2555
2613
  if (relativePath === 'roles.yaml') return null;
2614
+ // At a commit (what every gate passes: `resolvedRef`), the batched read
2615
+ // and its memo answer; a single `git show` stays for a symbolic ref.
2616
+ if (isCommitSha(ref)) {
2617
+ return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
2618
+ }
2556
2619
  const text = await this.showAtRef(repoDir, ref, relativePath);
2557
2620
  if (text === null) return null;
2558
2621
  if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
@@ -2571,23 +2634,60 @@ export class AccessControlService implements IAccessControl {
2571
2634
  repoDir: string,
2572
2635
  ref: string,
2573
2636
  relativePaths: string[],
2637
+ tree: ReadonlySet<string> | null = null,
2574
2638
  ): Promise<Map<string, OwnEntries | null>> {
2575
2639
  const result = new Map<string, OwnEntries | null>();
2576
2640
  const wanted: string[] = [];
2641
+ const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
2577
2642
  for (const p of relativePaths) {
2578
- if (p === 'roles.yaml') result.set(p, null);
2579
- else if (!wanted.includes(p)) wanted.push(p);
2643
+ if (p === 'roles.yaml') {
2644
+ result.set(p, null);
2645
+ continue;
2646
+ }
2647
+ const key = `${ref}\0${p}`;
2648
+ if (memo?.has(key)) {
2649
+ result.set(p, memo.get(key) ?? null);
2650
+ continue;
2651
+ }
2652
+ // A path the commit's tree does not carry has no own entries, as a
2653
+ // fact about the commit: remembered, and not read. (A change request
2654
+ // that adds a file asks about it at the base, where it does not exist
2655
+ // yet; one that deletes a file asks at the head.)
2656
+ if (tree && !tree.has(p)) {
2657
+ result.set(p, null);
2658
+ if (memo) this.rememberOwnEntriesAtCommit(key, null);
2659
+ continue;
2660
+ }
2661
+ if (!wanted.includes(p)) wanted.push(p);
2580
2662
  }
2581
2663
  if (wanted.length === 0) return result;
2582
2664
  const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
2583
2665
  wanted.forEach((p, i) => {
2584
2666
  const text = texts[i];
2585
- if (text === null) result.set(p, null);
2586
- else result.set(p, isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text));
2667
+ if (text === null) {
2668
+ // Absent at the commit (when no tree was there to say so first), or
2669
+ // a blob git could not read: the batch says "missing" for both, and
2670
+ // only the first is a fact about the commit. Answered as no own
2671
+ // entries, as before, but never remembered.
2672
+ result.set(p, null);
2673
+ return;
2674
+ }
2675
+ const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
2676
+ result.set(p, own);
2677
+ // What a blob declares for itself — including nothing — is a fact about the commit.
2678
+ if (memo) this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
2587
2679
  });
2588
2680
  return result;
2589
2681
  }
2590
2682
 
2683
+ private rememberOwnEntriesAtCommit(key: string, value: OwnEntries | null): void {
2684
+ this.ownEntriesAtCommit.set(key, value);
2685
+ if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
2686
+ const oldest = this.ownEntriesAtCommit.keys().next().value;
2687
+ if (oldest !== undefined) this.ownEntriesAtCommit.delete(oldest);
2688
+ }
2689
+ }
2690
+
2591
2691
  /**
2592
2692
  * Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
2593
2693
  * for short branch names, matching the heuristic in
@@ -2649,11 +2749,12 @@ export class AccessControlService implements IAccessControl {
2649
2749
  private async listAccessFilesAtRef(
2650
2750
  repoDir: string,
2651
2751
  ref: string,
2652
- ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string> } | 'error'> {
2752
+ ): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string>; all: string[] } | 'error'> {
2653
2753
  try {
2654
2754
  const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
2655
2755
  const out: string[] = [];
2656
2756
  const pluginDirs: string[] = [];
2757
+ const all: string[] = [];
2657
2758
  // The fixed-name sources the tree carries (roles.yaml, the groups
2658
2759
  // files): a read of one of these that comes back empty is "absent"
2659
2760
  // only when the tree does not name it.
@@ -2661,13 +2762,15 @@ export class AccessControlService implements IAccessControl {
2661
2762
  const { pluginsDir } = this.kb.layout;
2662
2763
  for (const p of stdout.split('\0')) {
2663
2764
  if (!p) continue;
2765
+ // One past the cap is enough to know the tree is not kept.
2766
+ if (all.length <= this.treeIndexMaxPaths) all.push(p);
2664
2767
  if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
2665
2768
  else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
2666
2769
  else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
2667
2770
  pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
2668
2771
  }
2669
2772
  }
2670
- return { accessFiles: out, pluginDirs, named };
2773
+ return { accessFiles: out, pluginDirs, named, all };
2671
2774
  } catch {
2672
2775
  return 'error';
2673
2776
  }
@@ -2742,6 +2845,14 @@ export class AccessControlService implements IAccessControl {
2742
2845
  /** The commit `ref` points at in this clone, or null when it doesn't resolve. */
2743
2846
  private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
2744
2847
  if (!ref || ref.startsWith('-')) return null;
2848
+ // The ref files first (`readRevSha`): the names git would try, in git's
2849
+ // order, read without a process. "Missing" from the files is what
2850
+ // rev-parse answers nothing for; "unreadable" — a spelling the reader
2851
+ // does not resolve, a tag it cannot peel, a file it could not read — is
2852
+ // git's to decide, as the authority.
2853
+ const fromFiles = await readRevSha(repoDir, ref);
2854
+ if (fromFiles === 'missing') return null;
2855
+ if (fromFiles !== 'unreadable') return fromFiles.sha;
2745
2856
  try {
2746
2857
  const { stdout } = await this.gitRunner.run(repoDir, [
2747
2858
  'rev-parse',
@@ -2762,9 +2873,22 @@ export class AccessControlService implements IAccessControl {
2762
2873
 
2763
2874
  private rememberAtRef(key: string, value: AtRefModel): void {
2764
2875
  this.atRefCache.set(key, value);
2876
+ this.treePathsHeld += value.tree?.size ?? 0;
2765
2877
  if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
2766
2878
  const oldest = this.atRefCache.keys().next().value;
2767
- if (oldest !== undefined) this.atRefCache.delete(oldest);
2879
+ if (oldest !== undefined) {
2880
+ this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
2881
+ this.atRefCache.delete(oldest);
2882
+ }
2883
+ }
2884
+ // The trees are a budget of their own, across every held model: past
2885
+ // it, the oldest models keep their model and let their tree go — the
2886
+ // gate still answers, absence is just no longer remembered for them.
2887
+ for (const held of this.atRefCache.values()) {
2888
+ if (this.treePathsHeld <= this.treeIndexBudget) break;
2889
+ if (!held.tree) continue;
2890
+ this.treePathsHeld -= held.tree.size;
2891
+ held.tree = null;
2768
2892
  }
2769
2893
  }
2770
2894
 
@@ -2940,6 +3064,10 @@ export class AccessControlService implements IAccessControl {
2940
3064
  deploymentOwners: this.deploymentOwners,
2941
3065
  },
2942
3066
  resolvedRef: commit,
3067
+ // The listing already named every path; held so an own-entries read
3068
+ // can tell absence from a lost blob, unless the tree is too large to
3069
+ // hold for every cached commit (then absence is simply not remembered).
3070
+ tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
2943
3071
  };
2944
3072
  }
2945
3073
  }
@@ -128,6 +128,18 @@ export function createAccessRoutes(
128
128
  return { roles, groups, plugins: plugins ?? [] };
129
129
  };
130
130
 
131
+ /**
132
+ * The groups `email` may be offered as a grantee, or name in a grant: the
133
+ * resolver's ONE answer to who sees which group (`visibleGroups`: every
134
+ * group for an admin, only their own for anyone else), the same the
135
+ * Library's Groups tab lists. Read on the default branch, where the active
136
+ * group source lives. The picker never names a group the caller has no
137
+ * part in, and the grant route refuses one with the same words as a group
138
+ * that does not exist.
139
+ */
140
+ const visibleGroups = (email: string, groups: string[]): Promise<string[]> =>
141
+ accessControl.visibleGroups(kb.defaultWorkspaceId(), email, groups);
142
+
131
143
  /**
132
144
  * The plugins `email` may DISCOVER — read the folder's access.md, the same
133
145
  * verdict the plugin index lists on. Both the suggest list and the grant
@@ -491,8 +503,9 @@ export function createAccessRoutes(
491
503
  * names — resolved from the DEFAULT branch, the authoritative source),
492
504
  * groups (the ACTIVE group source, same authority), and people (branch-local
493
505
  * to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
494
- * file reads per keystroke. Roles + groups are small + non-sensitive so they
495
- * always show; PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
506
+ * file reads per keystroke. Roles are small + non-sensitive so they always
507
+ * show; GROUPS are the caller's own unless they are an admin (see
508
+ * `visibleGroups`); PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
496
509
  * can't dump the whole directory (email-harvesting guard). People are the
497
510
  * union of the KB-canonical set (roles.yaml + access.md grants) and the
498
511
  * `users` table (logged-in users). Results are capped. Each person carries
@@ -527,7 +540,7 @@ export function createAccessRoutes(
527
540
  .filter((g) => !q || g.toLowerCase().includes(q))
528
541
  .slice(0, CAP);
529
542
 
530
- const matchedGroups = groups
543
+ const matchedGroups = (await visibleGroups(user.email, groups))
531
544
  .filter((g) => !q || g.toLowerCase().includes(q))
532
545
  .slice(0, CAP);
533
546
 
@@ -934,9 +947,12 @@ export function createAccessRoutes(
934
947
  } else if (principal.kind === 'group') {
935
948
  // A group grant must name a group in the ACTIVE source (IdP-synced or
936
949
  // manual) on the default branch — same authority the suggest list
937
- // uses. No role-collision refusal: bare tokens resolve GROUP-FIRST,
938
- // so the written bare name IS the group.
939
- const { groups } = await defaultBranchPrincipals();
950
+ // uses — and one the caller may see (`visibleGroups`): a group hidden
951
+ // from the picker is refused here with the same words as one that
952
+ // does not exist, so the grant route is no oracle for group names.
953
+ // No role-collision refusal: bare tokens resolve GROUP-FIRST, so the
954
+ // written bare name IS the group.
955
+ const groups = await visibleGroups(user.email, (await defaultBranchPrincipals()).groups);
940
956
  const canonical = canonicalRoleName(principal.group);
941
957
  if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
942
958
  throw new AccessMutationError(
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * They used to be appended, in full, to every tool description that they
5
5
  * covered: the content rule rode on all twelve file tools, the agent-guide
6
- * reminder on all of them plus the shell, the write-mode and escape-sequence
6
+ * reminder on all of them, the write-mode and escape-sequence
7
7
  * paragraphs on two or three apiece. A description then ran to two or three
8
8
  * thousand characters, most of it text the agent had already read on the tool
9
9
  * above — and clients cut long descriptions from the END, which is where the
@@ -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 {
@@ -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
+ });