@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
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
9
+ import { WorkflowHooks } from '../../workflow-hooks.js';
10
+ import { GitService } from '../git.service.js';
11
+ import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
12
+
13
+ /**
14
+ * Every git process the service starts, by argv, so a test can say what a
15
+ * change-request read is allowed to cost. The fixture's own git runs through
16
+ * `execFile` and is not logged.
17
+ */
18
+ const { spawnLog } = vi.hoisted(() => ({ spawnLog: [] as string[][] }));
19
+
20
+ vi.mock('node:child_process', async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import('node:child_process')>();
22
+ const spawn = ((file: string, args: string[], options: unknown) => {
23
+ if (file === 'git') spawnLog.push(args ?? []);
24
+ return (actual.spawn as unknown as (...a: unknown[]) => unknown)(file, args, options);
25
+ }) as unknown as typeof actual.spawn;
26
+ return { ...actual, spawn };
27
+ });
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ async function runGit(cwd: string, args: string[]): Promise<void> {
32
+ await execFileAsync('git', args, {
33
+ cwd,
34
+ env: {
35
+ ...process.env,
36
+ GIT_AUTHOR_NAME: 'Test',
37
+ GIT_AUTHOR_EMAIL: 't@x.com',
38
+ GIT_COMMITTER_NAME: 'Test',
39
+ GIT_COMMITTER_EMAIL: 't@x.com',
40
+ },
41
+ });
42
+ }
43
+
44
+ const spawnsOf = (subcommand: string) => spawnLog.filter((argv) => argv.includes(subcommand)).length;
45
+
46
+ /**
47
+ * What a change request's changed paths cost: the tips come from the ref
48
+ * files and the diff is read from git ONCE per pair of commits. A listing
49
+ * that nothing has moved under asks git for nothing; a push moves a tip,
50
+ * and that request alone is diffed again.
51
+ */
52
+ describe('a change request is diffed once per pair of commits', () => {
53
+ let root: string;
54
+ let upstream: string;
55
+ let repo: string;
56
+ let other: string;
57
+ let svc: GitService;
58
+ const workspaceId = 'ws-main';
59
+ const secondWorkspaceId = 'ws-two';
60
+
61
+ beforeEach(async () => {
62
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-changed-memo-'));
63
+ upstream = path.join(root, 'upstream.git');
64
+ await runGit(root, ['init', '--bare', '-b', 'main', upstream]);
65
+ const seed = path.join(root, '.seed');
66
+ await fs.mkdir(seed);
67
+ await runGit(seed, ['init', '-b', 'main']);
68
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
69
+ await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
70
+ await runGit(seed, ['add', '-A']);
71
+ await runGit(seed, ['commit', '-m', 'init']);
72
+ await runGit(seed, ['checkout', '-b', 'feature']);
73
+ await fs.writeFile(path.join(seed, 'one.md'), 'one\n');
74
+ await runGit(seed, ['add', '-A']);
75
+ await runGit(seed, ['commit', '-m', 'one']);
76
+ await runGit(seed, ['push', 'origin', 'main', 'feature']);
77
+ const workspaceDir = path.join(root, workspaceId);
78
+ repo = path.join(workspaceDir, 'knowledge-base');
79
+ await fs.mkdir(workspaceDir, { recursive: true });
80
+ await runGit(root, ['clone', upstream, repo]);
81
+ other = path.join(root, 'other');
82
+ await runGit(root, ['clone', upstream, other]);
83
+ // A second workspace of the same knowledge base, with its own reservation.
84
+ const secondDir = path.join(root, secondWorkspaceId);
85
+ await fs.mkdir(secondDir, { recursive: true });
86
+ await runGit(root, ['clone', upstream, path.join(secondDir, 'knowledge-base')]);
87
+ const workspace = {
88
+ getWorkspacePath: async (id: string) => {
89
+ if (id === workspaceId) return workspaceDir;
90
+ if (id === secondWorkspaceId) return secondDir;
91
+ throw new Error(`unexpected workspace ${id}`);
92
+ },
93
+ } as unknown as WorkspaceService;
94
+ svc = new GitService(
95
+ workspace,
96
+ new WorkflowHooks(),
97
+ testKbContext({ branchModel: { defaultBranch: 'main', protectedBranches: ['main'] } }),
98
+ new WorkspaceMutex(),
99
+ );
100
+ spawnLog.length = 0;
101
+ });
102
+
103
+ afterEach(async () => {
104
+ await fs.rm(root, { recursive: true, force: true });
105
+ });
106
+
107
+ it('reads the tips from the ref files, diffs once, and asks git nothing the second time', async () => {
108
+ const first = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
109
+ expect(first.paths).toEqual(['one.md']);
110
+ expect(first.pairs).toEqual([{ path: 'one.md' }]);
111
+ expect(spawnsOf('diff')).toBe(1);
112
+ expect(spawnsOf('rev-parse')).toBe(0);
113
+
114
+ spawnLog.length = 0;
115
+ const second = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
116
+ expect(second).toEqual(first);
117
+ expect(spawnLog).toEqual([]);
118
+
119
+ // The access-check flavour reads the same pair: the memo answers it too.
120
+ spawnLog.length = 0;
121
+ expect(await svc.changedPathsForPr(workspaceId, 'main', 'feature', { fetch: false, forAccessCheck: true })).toEqual(['one.md']);
122
+ expect(spawnLog).toEqual([]);
123
+ });
124
+
125
+ it('diffs again, once, when a push moves a tip', async () => {
126
+ await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
127
+ // Someone else pushes to feature; the workspace fetches as a listing's
128
+ // whole-clone refresh does, which rewrites the ref file.
129
+ await runGit(other, ['checkout', 'feature']);
130
+ await fs.writeFile(path.join(other, 'two.md'), 'two\n');
131
+ await runGit(other, ['add', '-A']);
132
+ await runGit(other, ['commit', '-m', 'two']);
133
+ await runGit(other, ['push', 'origin', 'feature']);
134
+ await runGit(repo, ['fetch', 'origin']);
135
+
136
+ spawnLog.length = 0;
137
+ const after = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
138
+ expect(after.paths.sort()).toEqual(['one.md', 'two.md']);
139
+ expect(spawnsOf('diff')).toBe(1);
140
+ expect(spawnsOf('rev-parse')).toBe(0);
141
+
142
+ spawnLog.length = 0;
143
+ expect(await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false })).toEqual(after);
144
+ expect(spawnLog).toEqual([]);
145
+ });
146
+
147
+ it('shares one diff between concurrent reads of a pair nobody has seen, even from two workspaces', async () => {
148
+ // Two workspaces take two reservations, so nothing serialises the two
149
+ // reads but the in-flight diff itself: both answer, one `diff` runs.
150
+ const [a, b] = await Promise.all([
151
+ svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false }),
152
+ svc.changedPathsAndPairsForPr(secondWorkspaceId, 'main', 'feature', { fetch: false }),
153
+ ]);
154
+ expect(a).toEqual(b);
155
+ expect(a.paths).toEqual(['one.md']);
156
+ expect(spawnsOf('diff')).toBe(1);
157
+ });
158
+
159
+ it('still asks git about a branch the ref files do not hold, and answers as before', async () => {
160
+ // A branch this clone has never fetched: the files say missing for both
161
+ // of its names, which is rev-parse's answer too — and `fetch: false`
162
+ // then turns into the per-request fetch the caller was promised.
163
+ await runGit(other, ['checkout', '-b', 'later']);
164
+ await fs.writeFile(path.join(other, 'three.md'), 'three\n');
165
+ await runGit(other, ['add', '-A']);
166
+ await runGit(other, ['commit', '-m', 'three']);
167
+ await runGit(other, ['push', 'origin', 'later']);
168
+
169
+ spawnLog.length = 0;
170
+ const got = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'later', { fetch: false });
171
+ expect(got.paths).toEqual(['three.md']);
172
+ expect(spawnsOf('fetch')).toBe(1);
173
+ expect(spawnsOf('diff')).toBe(1);
174
+ });
175
+ });
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import type { AuthUser } from '@bevel-software/platform-shared';
9
+ import { NodeFs } from '../../../kb-fs/node-fs.js';
10
+ import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
11
+ import { WorkspaceService } from '../../../workspace/workspace.service.js';
12
+ import { KbRepository, repositoryDirFor } from '../../../workspace/kb-repository.js';
13
+ import { WorkflowHooks } from '../../workflow-hooks.js';
14
+ import { GitService } from '../git.service.js';
15
+ import { NodeGitRunner } from '../node-git-runner.js';
16
+ import { workspaceIdForBranch } from '../../../../shared/workspace-id.js';
17
+ import { newWorkspaceService } from '../../../../__tests__/workspace-service.js';
18
+
19
+ /**
20
+ * The git layer over a knowledge base of WORKTREES of one repository: the
21
+ * same answers as over clones of the same remote, two worktrees committing at
22
+ * once, and a branch deleted while its own worktree is open. Real git.
23
+ */
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const TARGET = 'current-company-state';
27
+ const ALICE: AuthUser = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' };
28
+
29
+ async function git(cwd: string, args: string[]): Promise<string> {
30
+ const { stdout } = await execFileAsync('git', args, {
31
+ cwd,
32
+ env: {
33
+ ...process.env,
34
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
35
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
36
+ },
37
+ });
38
+ return stdout.toString();
39
+ }
40
+
41
+ let root: string;
42
+ let upstream: string;
43
+ let workspacesRoot: string;
44
+ let workspaces: WorkspaceService;
45
+ let gitService: GitService;
46
+
47
+ beforeEach(async () => {
48
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-worktrees-'));
49
+ workspacesRoot = path.join(root, 'workspaces');
50
+ await fs.mkdir(workspacesRoot, { recursive: true });
51
+ upstream = path.join(root, 'upstream.git');
52
+ await git(root, ['init', '--bare', '-b', TARGET, upstream]);
53
+ const seed = path.join(root, '.seed');
54
+ await fs.mkdir(seed);
55
+ await git(seed, ['init', '-b', TARGET]);
56
+ await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
57
+ await git(seed, ['add', '-A']);
58
+ await git(seed, ['commit', '-m', 'init']);
59
+ for (const draft of ['alice/a', 'bob/b']) {
60
+ await git(seed, ['checkout', '-q', '-b', draft, TARGET]);
61
+ await fs.writeFile(path.join(seed, `${draft.split('/')[0]}.md`), `${draft}\n`);
62
+ await git(seed, ['add', '-A']);
63
+ await git(seed, ['commit', '-m', `on ${draft}`]);
64
+ }
65
+ await git(seed, ['push', '-q', upstream, TARGET, 'alice/a', 'bob/b']);
66
+
67
+ const mutex = new WorkspaceMutex();
68
+ const runner = new NodeGitRunner();
69
+ const repository = new KbRepository({
70
+ dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
71
+ kbRepoUrl: () => upstream,
72
+ gitRunner: runner,
73
+ });
74
+ workspaces = newWorkspaceService(
75
+ workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
76
+ );
77
+ gitService = new GitService(workspaces, new WorkflowHooks(), testKbContext(), mutex, null, runner);
78
+ });
79
+
80
+ afterEach(async () => {
81
+ await fs.rm(root, { recursive: true, force: true });
82
+ });
83
+
84
+ const id = (branch: string) => workspaceIdForBranch(branch);
85
+ const checkoutOf = (branch: string) => path.join(workspacesRoot, id(branch), 'knowledge-base');
86
+
87
+ describe('GitService over worktrees', () => {
88
+ it('lands two commits requested at the same time in two worktrees, and pushes both', async () => {
89
+ await workspaces.getOrCreateForBranch('alice/a');
90
+ await workspaces.getOrCreateForBranch('bob/b');
91
+ await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
92
+ await fs.writeFile(path.join(checkoutOf('bob/b'), 'bob.md'), 'bob edits\n');
93
+
94
+ const [a, b] = await Promise.all([
95
+ gitService.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits'),
96
+ gitService.commitFile(id('bob/b'), ALICE, 'knowledge-base/bob.md', 'bob edits'),
97
+ ]);
98
+ expect(a).not.toBeNull();
99
+ expect(b).not.toBeNull();
100
+ await Promise.all([gitService.push(id('alice/a'), ALICE), gitService.push(id('bob/b'), ALICE)]);
101
+
102
+ expect(await git(upstream, ['show', 'alice/a:alice.md'])).toBe('alice edits\n');
103
+ expect(await git(upstream, ['show', 'bob/b:bob.md'])).toBe('bob edits\n');
104
+ // Each push left its branch tracking its own remote branch.
105
+ expect((await git(checkoutOf('alice/a'), ['rev-parse', '--abbrev-ref', '@{upstream}'])).trim()).toBe('origin/alice/a');
106
+ expect((await git(checkoutOf('bob/b'), ['status', '--porcelain'])).trim()).toBe('');
107
+ });
108
+
109
+ it("answers a change request's files and merges it exactly as a clone of the same remote does", async () => {
110
+ await workspaces.getOrCreateForBranch(TARGET);
111
+ await workspaces.getOrCreateForBranch('alice/a');
112
+ const onWorktrees = await gitService.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
113
+
114
+ // The same question asked of a full clone of the same remote.
115
+ const cloneRoot = path.join(root, 'clones');
116
+ const clone = path.join(cloneRoot, id(TARGET), 'knowledge-base');
117
+ await fs.mkdir(path.dirname(clone), { recursive: true });
118
+ await git(root, ['clone', '-q', '-b', TARGET, upstream, clone]);
119
+ const clones = newWorkspaceService(cloneRoot, upstream, testKbContext(), new NodeFs());
120
+ const onClones = await new GitService(clones, new WorkflowHooks(), testKbContext()).changedFilesForPr(
121
+ id(TARGET), TARGET, 'alice/a',
122
+ );
123
+ expect(onWorktrees).toEqual(onClones);
124
+
125
+ await gitService.mergeChangeRequest(
126
+ id(TARGET), 'alice/a', TARGET, { subject: 'Merge alice/a (#1)', body: '' }, ALICE,
127
+ );
128
+ expect(await git(upstream, ['show', `${TARGET}:alice.md`])).toBe('alice/a\n');
129
+ });
130
+
131
+ it('deletes a branch whose own worktree is open, and removing that workspace takes the worktree and its ref', async () => {
132
+ await workspaces.getOrCreateForBranch(TARGET);
133
+ await workspaces.getOrCreateForBranch('alice/a');
134
+
135
+ await gitService.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
136
+ await workspaces.deleteWorkspace(id('alice/a'));
137
+
138
+ await expect(git(upstream, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
139
+ await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
140
+ await expect(fs.access(checkoutOf('alice/a'))).rejects.toThrow();
141
+ expect((await git(checkoutOf(TARGET), ['status', '--porcelain'])).trim()).toBe('');
142
+ });
143
+
144
+ it("finishes a branch's first open raced by git operations on that branch, and a fetch joined from inside one", async () => {
145
+ // The first request for a branch fans out: the page opens the workspace
146
+ // while the git layer asks for its status — inside the branch's
147
+ // reservation, resolving the same workspace — and a listing fetches.
148
+ // The worktree add waits for running git operations, and they wait for
149
+ // the open: nothing may wait for itself.
150
+ await workspaces.getOrCreateForBranch(TARGET);
151
+ const all = Promise.all([
152
+ workspaces.getOrCreateForBranch('bob/b'),
153
+ gitService.status(id('bob/b')),
154
+ gitService.listBranches(id(TARGET), { freshFetch: true }),
155
+ gitService.status(id('bob/b')),
156
+ ]);
157
+ let timeout: ReturnType<typeof setTimeout> | undefined;
158
+ try {
159
+ await Promise.race([
160
+ all,
161
+ new Promise((_, reject) => {
162
+ timeout = setTimeout(() => reject(new Error('deadlocked')), 20_000);
163
+ }),
164
+ ]);
165
+ } finally {
166
+ if (timeout) clearTimeout(timeout);
167
+ }
168
+ expect((await git(checkoutOf('bob/b'), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('bob/b');
169
+ });
170
+
171
+ it('lists branches from one fetch of the repository, strictly', async () => {
172
+ await workspaces.getOrCreateForBranch(TARGET);
173
+ const branches = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
174
+ expect(branches.map((b) => b.name).sort()).toEqual(['alice/a', 'bob/b', TARGET].sort());
175
+ expect(gitService.lastRemoteContact()?.ok).toBe(true);
176
+ });
177
+
178
+ it("does not list another worktree's local branch once origin no longer has it, so the orphan sweep can take it", async () => {
179
+ await workspaces.getOrCreateForBranch(TARGET);
180
+ await workspaces.getOrCreateForBranch('bob/b');
181
+ // Deleted on the host while its worktree stays open: the repository
182
+ // still holds bob/b's local ref, as bob's clone held it.
183
+ await git(upstream, ['branch', '-D', 'bob/b']);
184
+
185
+ const fromTarget = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
186
+ expect(fromTarget.map((b) => b.name).sort()).toEqual(['alice/a', TARGET].sort());
187
+ // A clone never saw another branch's local ref either: listed from its
188
+ // own worktree, a branch is its own.
189
+ const fromBob = await gitService.listBranches(id('bob/b'), { freshFetch: true });
190
+ expect(fromBob.find((b) => b.name === 'bob/b')?.hasRemote).toBe(false);
191
+
192
+ const { removed } = await workspaces.sweepOrphanedWorkspaces(fromTarget.map((b) => b.name));
193
+ expect(removed).toEqual([id('bob/b')]);
194
+ await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/bob/b'])).rejects.toThrow();
195
+ });
196
+ });
197
+
198
+ describe('GitService and the repository administrator', () => {
199
+ /** The repository, noting whether its administrator's section is held while git runs. */
200
+ class RecordingRepository extends KbRepository {
201
+ depth = 0;
202
+ override exclusive<T>(fn: () => Promise<T>): Promise<T> {
203
+ return super.exclusive(async () => {
204
+ this.depth += 1;
205
+ try { return await fn(); } finally { this.depth -= 1; }
206
+ });
207
+ }
208
+ }
209
+
210
+ it('writes the shared config as the administrator, and runs everything else as itself', async () => {
211
+ const mutex = new WorkspaceMutex();
212
+ const runner = new NodeGitRunner();
213
+ const repository = new RecordingRepository({
214
+ dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
215
+ kbRepoUrl: () => upstream,
216
+ gitRunner: runner,
217
+ });
218
+ const recorded = newWorkspaceService(
219
+ workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
220
+ );
221
+ const calls: { args: string[]; held: boolean }[] = [];
222
+ const recordingRunner = {
223
+ defaultTimeoutMs: runner.defaultTimeoutMs,
224
+ credentials: runner.credentials,
225
+ run: ((cwd: string, args: string[], opts?: never) => {
226
+ calls.push({ args, held: repository.depth > 0 });
227
+ return runner.run(cwd, args, opts);
228
+ }) as NodeGitRunner['run'],
229
+ };
230
+ const service = new GitService(recorded, new WorkflowHooks(), testKbContext(), mutex, null, recordingRunner);
231
+ await recorded.getOrCreateForBranch(TARGET);
232
+ await recorded.getOrCreateForBranch('alice/a');
233
+
234
+ await service.fetch(id(TARGET));
235
+ await service.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
236
+ await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
237
+ await service.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits');
238
+ await service.push(id('alice/a'), ALICE);
239
+ await service.status(id('alice/a'));
240
+ // A merge stamps the committer's name and email into the config.
241
+ const merged = await service.mergeChangeRequest(
242
+ id(TARGET), 'alice/a', TARGET, { subject: 'alice edits (#1)', body: '' }, ALICE,
243
+ );
244
+ expect(merged.kind).toBe('merged');
245
+ await service.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
246
+
247
+ // The subcommand, past leading `-c key=value` / `-C dir` options.
248
+ const sub = (args: string[]) => {
249
+ let i = 0;
250
+ while (args[i] === '-c' || args[i] === '-C') i += 2;
251
+ return { name: args[i], rest: args.slice(i + 1) };
252
+ };
253
+ const of = (pred: (s: { name: string; rest: string[] }) => boolean) => calls.filter((c) => pred(sub(c.args)));
254
+
255
+ // The config is the one file git refuses a second writer of: every write
256
+ // to it (the upstream stamp, the committer's name and email, a branch
257
+ // delete that drops the branch's section) is the administrator's. The
258
+ // refs, packed-refs and the objects git locks itself, so a fetch, a
259
+ // commit, a push, a merge and a read run as any worktree's git would.
260
+ const configRead = (rest: string[]) => rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(a));
261
+ const shared = [
262
+ ...of((s) => s.name === 'config' && !configRead(s.rest)),
263
+ ...of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')),
264
+ ];
265
+ const own = [
266
+ ...of((s) => s.name === 'fetch'),
267
+ ...of((s) => s.name === 'commit'),
268
+ ...of((s) => s.name === 'merge'),
269
+ ...of((s) => s.name === 'push' && !s.rest.includes('-u')),
270
+ ...of((s) => s.name === 'status'),
271
+ ...of((s) => s.name === 'config' && configRead(s.rest)),
272
+ ];
273
+ for (const name of ['fetch', 'config', 'commit', 'merge', 'push', 'status']) {
274
+ expect(calls.some((c) => sub(c.args).name === name), name).toBe(true);
275
+ }
276
+ expect(of((s) => s.name === 'config' && s.rest.includes('--replace-all')).length).toBeGreaterThan(0);
277
+ expect(of((s) => s.name === 'config' && s.rest[0] === 'user.name').length).toBeGreaterThan(0);
278
+ expect(of((s) => s.name === 'config' && s.rest[0] === 'user.email').length).toBeGreaterThan(0);
279
+ expect(of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')).length).toBeGreaterThan(0);
280
+ expect(shared.length).toBeGreaterThan(3);
281
+ for (const c of shared) expect(c.held, c.args.join(' ')).toBe(true);
282
+ for (const c of own) expect(c.held, c.args.join(' ')).toBe(false);
283
+ });
284
+ });