@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,613 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { workingCopyShape } from '../../kb-repository.js';
8
+ import { NodeGitRunner } from '../../../workflow/git/node-git-runner.js';
9
+ import { gitCredentials, type IGitRunner } from '../../../../shared/git.contract.js';
10
+ import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
11
+ import { newKbStartupRunner } from '../../../../__tests__/workspace-service.js';
12
+
13
+ /**
14
+ * The startup phase on a knowledge base of WORKTREES: it opens every branch as
15
+ * a worktree of one repository, converts the full clones an earlier release
16
+ * left behind — one at a time, without losing a commit — and sets the
17
+ * repository aside with every worktree of it when the address changes.
18
+ * Real git throughout.
19
+ */
20
+
21
+ const execFileAsync = promisify(execFile);
22
+ const credentials = gitCredentials('x-access-token', () => null);
23
+
24
+ const DEFAULT_BRANCH = 'current-company-state';
25
+ const PROTECTED = [DEFAULT_BRANCH, 'target-company-state'];
26
+ const DRAFTS = ['alice/a', 'bob/b', 'carol/c'];
27
+
28
+ async function git(cwd: string, args: string[]): Promise<string> {
29
+ const { stdout } = await execFileAsync('git', args, {
30
+ cwd,
31
+ env: {
32
+ ...process.env,
33
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
34
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
35
+ },
36
+ });
37
+ return stdout.toString();
38
+ }
39
+
40
+ let root: string;
41
+ let upstream: string;
42
+ let workspacesRoot: string;
43
+ let seed: string;
44
+
45
+ beforeEach(async () => {
46
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-startup-worktrees-'));
47
+ workspacesRoot = path.join(root, 'workspaces');
48
+ await fs.mkdir(workspacesRoot, { recursive: true });
49
+ upstream = path.join(root, 'upstream.git');
50
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, upstream]);
51
+ seed = path.join(root, '.seed');
52
+ await fs.mkdir(seed);
53
+ await git(seed, ['init', '-b', DEFAULT_BRANCH]);
54
+ await fs.writeFile(path.join(seed, 'marker.txt'), 'seeded', 'utf8');
55
+ await git(seed, ['add', '-A']);
56
+ await git(seed, ['commit', '-m', 'init']);
57
+ await git(seed, ['branch', PROTECTED[1]!]);
58
+ for (const draft of DRAFTS) {
59
+ await git(seed, ['checkout', '-q', '-b', draft, DEFAULT_BRANCH]);
60
+ await fs.writeFile(path.join(seed, 'draft.md'), draft, 'utf8');
61
+ await git(seed, ['add', '-A']);
62
+ await git(seed, ['commit', '-m', `on ${draft}`]);
63
+ }
64
+ await git(seed, ['checkout', '-q', DEFAULT_BRANCH]);
65
+ await git(seed, ['remote', 'add', 'origin', upstream]);
66
+ await git(seed, ['push', '-q', 'origin', ...PROTECTED, ...DRAFTS]);
67
+ });
68
+
69
+ afterEach(async () => {
70
+ vi.restoreAllMocks();
71
+ await fs.rm(root, { recursive: true, force: true });
72
+ });
73
+
74
+ function step(name: string, run: (ctx: ServerStartContext) => Promise<StepResult>): OnServerStart {
75
+ return { name, run };
76
+ }
77
+
78
+ /** Opens every remote branch, as the boot phase of a deployment does. */
79
+ const touchAll = step('touch-all', async (ctx) => {
80
+ for (const h of await ctx.allBranches()) await h.repoDir();
81
+ return { outcome: 'ok' };
82
+ });
83
+ const touchDefault = step('touch', async (ctx) => {
84
+ await (await ctx.defaultBranch()).repoDir();
85
+ return { outcome: 'ok' };
86
+ });
87
+
88
+ function makeRunner(steps: OnServerStart[], overrides: Record<string, unknown> = {}) {
89
+ return newKbStartupRunner({
90
+ gitRunner: new NodeGitRunner(undefined, credentials),
91
+ kbRepoUrl: () => upstream,
92
+ workspacesRoot,
93
+ setAsideRoot: path.join(root, 'set-aside'),
94
+ kbDirName: 'knowledge-base',
95
+ templateDir: path.join(root, 'template'),
96
+ defaultBranch: () => DEFAULT_BRANCH,
97
+ protectedBranches: () => PROTECTED,
98
+ seedAdminEmails: ['admin@example.com'],
99
+ unfinishedCloneQuietMs: 0,
100
+ steps,
101
+ buildSeedTree: async () => [],
102
+ ...overrides,
103
+ });
104
+ }
105
+
106
+ const checkoutOf = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
107
+ const repositoryDir = () => path.join(`${workspacesRoot}.repository`, 'knowledge-base.git');
108
+ const upstreamTip = async (branch: string) => (await git(upstream, ['rev-parse', branch])).trim();
109
+
110
+ /** A full clone of `branch` at its workspace path — what a release before worktrees left there. */
111
+ async function legacyClone(branch: string): Promise<string> {
112
+ const dir = checkoutOf(branch);
113
+ await fs.mkdir(path.dirname(dir), { recursive: true });
114
+ await git(root, ['clone', '-q', '-b', branch, upstream, dir]);
115
+ return dir;
116
+ }
117
+
118
+ async function commitIn(dir: string, file: string, content: string): Promise<string> {
119
+ await fs.writeFile(path.join(dir, file), content, 'utf8');
120
+ await git(dir, ['add', '-A']);
121
+ await git(dir, ['commit', '-qm', `wrote ${file}`]);
122
+ return (await git(dir, ['rev-parse', 'HEAD'])).trim();
123
+ }
124
+
125
+ async function objectStoresUnder(dir: string): Promise<string[]> {
126
+ const found: string[] = [];
127
+ for (const entry of await fs.readdir(dir, { recursive: true, withFileTypes: true })) {
128
+ if (entry.isDirectory() && entry.name === 'objects') found.push(path.join(entry.parentPath, entry.name));
129
+ }
130
+ return found;
131
+ }
132
+
133
+ async function sizeOf(dir: string): Promise<number> {
134
+ let total = 0;
135
+ for (const entry of await fs.readdir(dir, { recursive: true, withFileTypes: true })) {
136
+ if (entry.isFile()) total += (await fs.stat(path.join(entry.parentPath, entry.name))).size;
137
+ }
138
+ return total;
139
+ }
140
+
141
+ describe('a boot that opens every remote branch', () => {
142
+ it('opens each as a worktree of one repository, holds the history once, and fetches the repository once', async () => {
143
+ const inner = new NodeGitRunner(undefined, credentials);
144
+ const calls: string[][] = [];
145
+ const gitRunner = {
146
+ defaultTimeoutMs: inner.defaultTimeoutMs,
147
+ credentials: inner.credentials,
148
+ run: (cwd: string, args: string[], opts?: object) => {
149
+ calls.push(args);
150
+ return inner.run(cwd, args, opts as never);
151
+ },
152
+ } as unknown as IGitRunner;
153
+
154
+ await makeRunner([touchAll], { gitRunner }).runAll();
155
+
156
+ for (const branch of [...PROTECTED, ...DRAFTS]) {
157
+ expect(await workingCopyShape(checkoutOf(branch))).toBe('worktree');
158
+ expect((await git(checkoutOf(branch), ['rev-parse', 'HEAD'])).trim()).toBe(await upstreamTip(branch));
159
+ }
160
+ expect(calls.filter((args) => args.includes('clone'))).toEqual([]);
161
+ // The whole-remote fetch ran once — when the repository was made; every
162
+ // branch after that was within its window.
163
+ expect(calls.filter((args) => args.includes('fetch') && args.at(-1) === 'origin')).toHaveLength(1);
164
+ // And no branch asked the remote for itself on top of it.
165
+ expect(calls.filter((args) => args.includes('fetch') && args.some((a) => a.startsWith('+refs/heads/')))).toEqual([]);
166
+ // The history is in the repository, once; the workspaces root holds checkouts.
167
+ expect(await objectStoresUnder(workspacesRoot)).toEqual([]);
168
+ expect(await sizeOf(workspacesRoot)).toBeLessThan(await sizeOf(path.join(repositoryDir(), 'objects')));
169
+ });
170
+
171
+ it('does not fetch a branch again when the wait for the reservation outlasts the gap between the listing and the fetch', async () => {
172
+ const inner = new NodeGitRunner(undefined, credentials);
173
+ const calls: string[][] = [];
174
+ const gitRunner = {
175
+ defaultTimeoutMs: inner.defaultTimeoutMs,
176
+ credentials: inner.credentials,
177
+ run: async (cwd: string, args: string[], opts?: object) => {
178
+ calls.push(args);
179
+ // Each add's prune runs before it reads the repository's fetch: a
180
+ // slow one stands for a long wait under a loaded machine.
181
+ if (args[0] === 'worktree' && args[1] === 'prune') await new Promise((r) => setTimeout(r, 50));
182
+ return inner.run(cwd, args, opts as never);
183
+ },
184
+ } as unknown as IGitRunner;
185
+
186
+ await makeRunner([touchAll], { gitRunner }).runAll();
187
+
188
+ expect(calls.filter((args) => args.includes('fetch') && args.at(-1) === 'origin')).toHaveLength(1);
189
+ expect(calls.filter((args) => args.includes('fetch') && args.some((a) => a.startsWith('+refs/heads/')))).toEqual([]);
190
+ });
191
+
192
+ it('starts again over its own worktrees without converting or re-adding anything', async () => {
193
+ await makeRunner([touchAll]).runAll();
194
+ const log = vi.spyOn(console, 'log');
195
+ await makeRunner([touchAll]).runAll();
196
+ expect(log.mock.calls.flat().join('\n')).not.toMatch(/converted|Added the/);
197
+ for (const branch of [...PROTECTED, ...DRAFTS]) expect(await workingCopyShape(checkoutOf(branch))).toBe('worktree');
198
+ });
199
+
200
+ it('goes on when a branch is deleted on the remote after the boot listed it, and neither moves it nor brings it back', async () => {
201
+ await makeRunner([touchAll]).runAll();
202
+ const bobTip = await commitIn(checkoutOf('bob/b'), 'unpushed.md', 'kept');
203
+ // Deleted on the host between the boot's branch listing and the
204
+ // repository's fetch, which prunes its remote-tracking ref.
205
+ const deleteBobThenTouchAll = step('delete-then-touch-all', async (ctx) => {
206
+ await git(upstream, ['update-ref', '-d', 'refs/heads/bob/b']);
207
+ for (const h of await ctx.allBranches()) {
208
+ await h.repoDir();
209
+ h.write('maintained.md', 'by the boot');
210
+ }
211
+ return { outcome: 'ok' };
212
+ });
213
+
214
+ await makeRunner([deleteBobThenTouchAll]).runAll();
215
+
216
+ // Its worktree is as it was, the unpushed commit included.
217
+ expect(await workingCopyShape(checkoutOf('bob/b'))).toBe('worktree');
218
+ expect((await git(checkoutOf('bob/b'), ['rev-parse', 'HEAD'])).trim()).toBe(bobTip);
219
+ // The phase pushed nothing to it: the branch stays deleted.
220
+ expect((await git(upstream, ['branch', '--list', 'bob/b'])).trim()).toBe('');
221
+ // Every other branch was maintained as usual.
222
+ for (const branch of [...PROTECTED, 'alice/a', 'carol/c']) {
223
+ expect(await git(upstream, ['show', `${branch}:maintained.md`])).toBe('by the boot');
224
+ }
225
+ });
226
+
227
+ it('does not take a fetch from before its listing as current: a branch deleted since is not brought back', async () => {
228
+ const steps: OnServerStart[] = [touchAll];
229
+ const runner = makeRunner(steps);
230
+ // The same process ran the phase moments ago: its repository fetched
231
+ // well within the window.
232
+ await runner.runAll();
233
+ const deleteBobThenWriteAll = step('delete-then-write-all', async (ctx) => {
234
+ await git(upstream, ['update-ref', '-d', 'refs/heads/bob/b']);
235
+ for (const h of await ctx.allBranches()) {
236
+ await h.repoDir();
237
+ h.write('maintained.md', 'by the boot');
238
+ }
239
+ return { outcome: 'ok' };
240
+ });
241
+ steps.splice(0, steps.length, deleteBobThenWriteAll);
242
+
243
+ await runner.runAll();
244
+
245
+ expect((await git(upstream, ['branch', '--list', 'bob/b'])).trim()).toBe('');
246
+ expect(await git(upstream, ['show', 'alice/a:maintained.md'])).toBe('by the boot');
247
+ });
248
+
249
+ it('maintains a branch again at the next run once it is back on the remote', async () => {
250
+ const steps: OnServerStart[] = [touchAll];
251
+ const runner = makeRunner(steps);
252
+ await runner.runAll();
253
+ const bobTip = (await git(upstream, ['rev-parse', 'bob/b'])).trim();
254
+ steps.splice(
255
+ 0,
256
+ steps.length,
257
+ step('delete-then-write-all', async (ctx) => {
258
+ await git(upstream, ['update-ref', '-d', 'refs/heads/bob/b']);
259
+ for (const h of await ctx.allBranches()) await h.repoDir();
260
+ return { outcome: 'ok' };
261
+ }),
262
+ );
263
+ await runner.runAll();
264
+ expect((await git(upstream, ['branch', '--list', 'bob/b'])).trim()).toBe('');
265
+
266
+ // Pushed again by its owner; the next run of the same process writes to it.
267
+ await git(upstream, ['update-ref', 'refs/heads/bob/b', bobTip]);
268
+ steps.splice(0, steps.length, step('write-all', async (ctx) => {
269
+ for (const h of await ctx.allBranches()) {
270
+ await h.repoDir();
271
+ h.write('maintained.md', 'by the boot');
272
+ }
273
+ return { outcome: 'ok' };
274
+ }));
275
+ await runner.runAll();
276
+
277
+ expect(await git(upstream, ['show', 'bob/b:maintained.md'])).toBe('by the boot');
278
+ });
279
+ });
280
+
281
+ describe('a deployment upgraded over full clones', () => {
282
+ it('converts every clone to a worktree, keeps the commits the remote lacks, and removes the clones', async () => {
283
+ for (const branch of [...PROTECTED, ...DRAFTS]) await legacyClone(branch);
284
+ // Two commits a failed push left on one branch.
285
+ const alice = checkoutOf('alice/a');
286
+ await commitIn(alice, 'one.md', 'first unpushed');
287
+ const aliceTip = await commitIn(alice, 'two.md', 'second unpushed');
288
+ // And a branch that moved on the remote after its clone was made.
289
+ await git(seed, ['checkout', '-q', 'bob/b']);
290
+ await commitIn(seed, 'later.md', 'pushed later');
291
+ await git(seed, ['push', '-q', 'origin', 'bob/b']);
292
+
293
+ await makeRunner([touchAll]).runAll();
294
+
295
+ for (const branch of [...PROTECTED, ...DRAFTS]) {
296
+ expect(await workingCopyShape(checkoutOf(branch))).toBe('worktree');
297
+ await expect(fs.access(path.join(path.dirname(checkoutOf(branch)), '.knowledge-base.converting'))).rejects.toThrow();
298
+ }
299
+ // The branch with local commits is at its own tip, both commits on it.
300
+ expect((await git(alice, ['rev-parse', 'HEAD'])).trim()).toBe(aliceTip);
301
+ expect((await git(alice, ['rev-list', '--count', 'origin/alice/a..HEAD'])).trim()).toBe('2');
302
+ expect(await fs.readFile(path.join(alice, 'one.md'), 'utf8')).toBe('first unpushed');
303
+ // Every other branch is at the remote's tip.
304
+ for (const branch of [...PROTECTED, 'bob/b', 'carol/c']) {
305
+ expect((await git(checkoutOf(branch), ['rev-parse', 'HEAD'])).trim()).toBe(await upstreamTip(branch));
306
+ }
307
+ // The clones' own histories are gone from the workspaces root.
308
+ expect(await objectStoresUnder(workspacesRoot)).toEqual([]);
309
+ });
310
+
311
+ it('keeps the stash and the other local refs of a clone it converts in the repository', async () => {
312
+ const alice = await legacyClone('alice/a');
313
+ // Two stash entries: recovery work set aside, in no branch.
314
+ await fs.writeFile(path.join(alice, 'draft.md'), 'stashed first', 'utf8');
315
+ await git(alice, ['stash', 'push', '-q', '-m', 'first']);
316
+ await fs.writeFile(path.join(alice, 'draft.md'), 'stashed second', 'utf8');
317
+ await git(alice, ['stash', 'push', '-q', '-m', 'second']);
318
+ // A local branch with a commit the remote lacks, and a local tag.
319
+ await git(alice, ['checkout', '-q', '-b', 'alice/side']);
320
+ const sideTip = await commitIn(alice, 'side.md', 'on a side branch');
321
+ await git(alice, ['tag', '-a', '-m', 'mark', 'alice-mark']);
322
+ const tagObject = (await git(alice, ['rev-parse', 'refs/tags/alice-mark'])).trim();
323
+ await git(alice, ['checkout', '-q', 'alice/a']);
324
+ // A local branch the remote has at the same tip: nothing of its own.
325
+ await git(alice, ['branch', '-q', 'bob/b', 'origin/bob/b']);
326
+ const stashes = (await git(alice, ['reflog', 'show', '--format=%H', 'refs/stash'])).trim().split('\n');
327
+ const info = vi.spyOn(console, 'log').mockImplementation(() => {});
328
+
329
+ await makeRunner([touchDefault]).runAll();
330
+
331
+ expect(await workingCopyShape(alice)).toBe('worktree');
332
+ const ns = 'refs/kb-converted/alice%2Fa';
333
+ const held = (ref: string) => git(repositoryDir(), ['rev-parse', ref]).then((o) => o.trim());
334
+ expect(stashes).toHaveLength(2);
335
+ expect(await held(`${ns}/stash/0`)).toBe(stashes[0]);
336
+ expect(await held(`${ns}/stash/1`)).toBe(stashes[1]);
337
+ expect(await git(repositoryDir(), ['show', `${ns}/stash/0:draft.md`])).toBe('stashed second');
338
+ expect(await git(repositoryDir(), ['show', `${ns}/stash/1:draft.md`])).toBe('stashed first');
339
+ expect(await held(`${ns}/heads/alice/side`)).toBe(sideTip);
340
+ expect(await held(`${ns}/tags/alice-mark`)).toBe(tagObject);
341
+ await expect(held(`${ns}/heads/bob/b`)).rejects.toThrow();
342
+ // Read from the worktree as from any other ref of the repository.
343
+ expect(await git(alice, ['show', `${ns}/heads/alice/side:side.md`])).toBe('on a side branch');
344
+ expect(info.mock.calls.flat().join('\n')).toMatch(/"alice\/a" clone's stash and other local refs are kept/);
345
+ // The collection the startup phase runs keeps them.
346
+ await git(repositoryDir(), ['gc', '--prune=now', '--quiet']);
347
+ expect((await git(repositoryDir(), ['cat-file', '-t', stashes[1]!])).trim()).toBe('commit');
348
+ });
349
+
350
+ it('keeps a clone it cannot convert working as a clone, says which branch, converts the rest, and tries again at the next boot', async () => {
351
+ for (const branch of [DEFAULT_BRANCH, 'alice/a', 'bob/b']) await legacyClone(branch);
352
+ const alice = checkoutOf('alice/a');
353
+ // Work in the working tree, not yet in a commit: not the conversion's to discard.
354
+ await fs.writeFile(path.join(alice, 'draft.md'), 'being edited', 'utf8');
355
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
356
+
357
+ await makeRunner([touchDefault]).runAll();
358
+
359
+ expect(await workingCopyShape(alice)).toBe('clone');
360
+ expect(await fs.readFile(path.join(alice, 'draft.md'), 'utf8')).toBe('being edited');
361
+ expect(warn.mock.calls.flat().join('\n')).toMatch(/"alice\/a" working copy is still a full clone/);
362
+ expect(await workingCopyShape(checkoutOf('bob/b'))).toBe('worktree');
363
+ expect(await workingCopyShape(checkoutOf(DEFAULT_BRANCH))).toBe('worktree');
364
+
365
+ // Committed since: the next boot converts it, commit and all.
366
+ const tip = await commitIn(alice, 'draft.md', 'being edited');
367
+ await makeRunner([touchDefault]).runAll();
368
+ expect(await workingCopyShape(alice)).toBe('worktree');
369
+ expect((await git(alice, ['rev-parse', 'HEAD'])).trim()).toBe(tip);
370
+ });
371
+
372
+ it('leaves a clone whose objects cannot be read in place, and the boot goes on', async () => {
373
+ for (const branch of [DEFAULT_BRANCH, 'alice/a']) await legacyClone(branch);
374
+ const alice = checkoutOf('alice/a');
375
+ const tip = await commitIn(alice, 'lost.md', 'unreadable');
376
+ // The commit's own object is gone: nothing can read the branch's tip.
377
+ await fs.rm(path.join(alice, '.git', 'objects', tip.slice(0, 2), tip.slice(2)));
378
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
379
+
380
+ await makeRunner([touchDefault]).runAll();
381
+
382
+ expect(await workingCopyShape(alice)).toBe('clone');
383
+ expect((await git(alice, ['rev-parse', 'refs/heads/alice/a'])).trim()).toBe(tip);
384
+ expect(warn.mock.calls.flat().join('\n')).toMatch(/"alice\/a" working copy is still a full clone/);
385
+ expect(await workingCopyShape(checkoutOf(DEFAULT_BRANCH))).toBe('worktree');
386
+ });
387
+
388
+ it('finishes a conversion a crash cut short, with the clone moved aside and no worktree yet', async () => {
389
+ const alice = await legacyClone('alice/a');
390
+ const tip = await commitIn(alice, 'unpushed.md', 'kept');
391
+ await fs.rename(alice, path.join(path.dirname(alice), '.knowledge-base.converting'));
392
+
393
+ await makeRunner([touchDefault]).runAll();
394
+
395
+ expect(await workingCopyShape(alice)).toBe('worktree');
396
+ expect((await git(alice, ['rev-parse', 'HEAD'])).trim()).toBe(tip);
397
+ await expect(fs.access(path.join(path.dirname(alice), '.knowledge-base.converting'))).rejects.toThrow();
398
+ });
399
+
400
+ it('keeps the files git ignores in a clone it converts', async () => {
401
+ const alice = await legacyClone('alice/a');
402
+ await commitIn(alice, '.gitignore', '*.local\ncache/\n');
403
+ await fs.writeFile(path.join(alice, 'notes.local'), 'not in any commit', 'utf8');
404
+ await fs.mkdir(path.join(alice, 'cache', 'deep'), { recursive: true });
405
+ await fs.writeFile(path.join(alice, 'cache', 'deep', 'index.bin'), 'cached', 'utf8');
406
+
407
+ await makeRunner([touchDefault]).runAll();
408
+
409
+ expect(await workingCopyShape(alice)).toBe('worktree');
410
+ expect(await fs.readFile(path.join(alice, 'notes.local'), 'utf8')).toBe('not in any commit');
411
+ expect(await fs.readFile(path.join(alice, 'cache', 'deep', 'index.bin'), 'utf8')).toBe('cached');
412
+ expect((await git(alice, ['status', '--porcelain=v1'])).trim()).toBe('');
413
+ await expect(fs.access(path.join(path.dirname(alice), '.knowledge-base.converting'))).rejects.toThrow();
414
+ });
415
+
416
+ it('keeps the ignored files of a clone a crash left beside its worktree before it removes the clone', async () => {
417
+ await makeRunner([touchAll]).runAll();
418
+ const alice = checkoutOf('alice/a');
419
+ // A crash after the worktree came up, before the clone's ignored files were copied.
420
+ const aside = path.join(path.dirname(alice), '.knowledge-base.converting');
421
+ await git(root, ['clone', '-q', '-b', 'alice/a', upstream, aside]);
422
+ await fs.appendFile(path.join(aside, '.git', 'info', 'exclude'), '\nnotes.local\n', 'utf8');
423
+ await fs.writeFile(path.join(aside, 'notes.local'), 'not in any commit', 'utf8');
424
+ // And a copy of it the crash cut short.
425
+ await fs.writeFile(path.join(alice, 'notes.local'), 'not in', 'utf8');
426
+
427
+ await makeRunner([]).runAll();
428
+
429
+ expect(await workingCopyShape(alice)).toBe('worktree');
430
+ expect(await fs.readFile(path.join(alice, 'notes.local'), 'utf8')).toBe('not in any commit');
431
+ await expect(fs.access(aside)).rejects.toThrow();
432
+ });
433
+
434
+ it('never copies from a converted clone it could not remove, so edits made since to ignored files stay', async () => {
435
+ const alice = await legacyClone('alice/a');
436
+ await commitIn(alice, '.gitignore', '*.local\n');
437
+ await fs.writeFile(path.join(alice, 'notes.local'), 'from the clone', 'utf8');
438
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
439
+ // The moved clone cannot be removed, whatever it is called by then.
440
+ const rm = fs.rm.bind(fs);
441
+ const refuse = vi.spyOn(fs, 'rm').mockImplementation(async (target, opts) => {
442
+ if (/\.knowledge-base\.convert(ing|ed)$/.test(String(target))) throw Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' });
443
+ return rm(target, opts);
444
+ });
445
+ try {
446
+ await makeRunner([touchDefault]).runAll();
447
+ expect(await workingCopyShape(alice)).toBe('worktree');
448
+ expect(await fs.readFile(path.join(alice, 'notes.local'), 'utf8')).toBe('from the clone');
449
+ expect(warn.mock.calls.flat().join('\n')).toMatch(/converted clone of "alice\/a" could not be removed/);
450
+
451
+ // Used since: an ignored file is edited in the worktree.
452
+ await fs.writeFile(path.join(alice, 'notes.local'), 'edited since', 'utf8');
453
+ await makeRunner([touchDefault]).runAll();
454
+
455
+ expect(await workingCopyShape(alice)).toBe('worktree');
456
+ expect(await fs.readFile(path.join(alice, 'notes.local'), 'utf8')).toBe('edited since');
457
+ } finally {
458
+ refuse.mockRestore();
459
+ }
460
+ // Once it can be removed, the next start removes it.
461
+ await makeRunner([touchDefault]).runAll();
462
+ await expect(fs.access(path.join(path.dirname(alice), '.knowledge-base.converted'))).rejects.toThrow();
463
+ expect(await fs.readFile(path.join(alice, 'notes.local'), 'utf8')).toBe('edited since');
464
+ });
465
+
466
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
467
+ 'leaves both folders of a cut-short conversion alone when the worktree\'s link cannot be read (EACCES is not absence)',
468
+ async () => {
469
+ await makeRunner([touchAll]).runAll();
470
+ const alice = checkoutOf('alice/a');
471
+ await fs.writeFile(path.join(alice, 'unsaved.md'), 'kept');
472
+ // A crash after the worktree came up, before the moved clone was removed.
473
+ const aside = path.join(path.dirname(alice), '.knowledge-base.converting');
474
+ await git(root, ['clone', '-q', '-b', 'alice/a', upstream, aside]);
475
+ const gitdir = (await fs.readFile(path.join(alice, '.git'), 'utf-8')).replace(/^gitdir:\s*/, '').trim();
476
+ const records = path.dirname(gitdir);
477
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
478
+ await fs.chmod(records, 0o000);
479
+ try {
480
+ // No step opens a branch: every worktree record is unreadable now.
481
+ await makeRunner([]).runAll();
482
+ } finally {
483
+ await fs.chmod(records, 0o755);
484
+ }
485
+
486
+ expect(await fs.readFile(path.join(alice, 'unsaved.md'), 'utf-8')).toBe('kept');
487
+ expect(await workingCopyShape(alice)).toBe('worktree');
488
+ expect(await workingCopyShape(aside)).toBe('clone');
489
+ expect(warn.mock.calls.flat().join('\n')).toMatch(/interrupted conversion of the "alice\/a" working copy/);
490
+ },
491
+ );
492
+
493
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
494
+ 'names a working copy whose link cannot be read, leaves it as it is, and looks again at the next boot',
495
+ async () => {
496
+ await makeRunner([touchAll]).runAll();
497
+ const alice = checkoutOf('alice/a');
498
+ await fs.writeFile(path.join(alice, 'unsaved.md'), 'kept');
499
+ const gitdir = (await fs.readFile(path.join(alice, '.git'), 'utf-8')).replace(/^gitdir:\s*/, '').trim();
500
+ const records = path.dirname(gitdir);
501
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
502
+ const log = vi.spyOn(console, 'log');
503
+ await fs.chmod(records, 0o000);
504
+ try {
505
+ await makeRunner([]).runAll();
506
+ } finally {
507
+ await fs.chmod(records, 0o755);
508
+ }
509
+
510
+ expect(await fs.readFile(path.join(alice, 'unsaved.md'), 'utf-8')).toBe('kept');
511
+ expect(await workingCopyShape(alice)).toBe('worktree');
512
+ expect(warn.mock.calls.flat().join('\n')).toMatch(/"alice\/a" working copy could not be inspected for conversion/);
513
+ // The summary does not call a worktree it could not read a clone.
514
+ const summary = log.mock.calls.flat().join('\n');
515
+ expect(summary).toMatch(/left as they are, to be tried again at the next start \([^)]*alice\/a/);
516
+ expect(summary).not.toMatch(/kept as clones/);
517
+ },
518
+ );
519
+ });
520
+
521
+ describe('the repository address changes', () => {
522
+ it('sets the repository and all its worktrees aside together, and the new address gets a fresh repository and worktrees', async () => {
523
+ await makeRunner([touchAll]).runAll();
524
+ const unpushed = await commitIn(checkoutOf(DEFAULT_BRANCH), 'unpushed.md', 'the only copy');
525
+
526
+ // An unrelated repository with the same branch names.
527
+ const replacement = path.join(root, 'replacement.git');
528
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, replacement]);
529
+ const other = path.join(root, '.seed-replacement');
530
+ await fs.mkdir(other);
531
+ await git(other, ['init', '-b', DEFAULT_BRANCH]);
532
+ await fs.writeFile(path.join(other, 'marker.txt'), 'replacement', 'utf8');
533
+ await git(other, ['add', '-A']);
534
+ await git(other, ['commit', '-m', 'init']);
535
+ await git(other, ['branch', PROTECTED[1]!]);
536
+ await git(other, ['push', '-q', replacement, ...PROTECTED]);
537
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
538
+ const discarded: string[] = [];
539
+
540
+ await makeRunner([touchDefault], {
541
+ kbRepoUrl: () => replacement,
542
+ onCloneDiscarded: (id: string) => void discarded.push(id),
543
+ }).runAll();
544
+
545
+ const [stamp] = await fs.readdir(path.join(root, 'set-aside'));
546
+ const kept = path.join(root, 'set-aside', stamp!);
547
+ expect((await fs.readdir(kept)).sort()).toEqual(
548
+ ['knowledge-base.git', ...[...PROTECTED, ...DRAFTS].map((b) => encodeURIComponent(b))].sort(),
549
+ );
550
+ expect(discarded.sort()).toEqual([...PROTECTED, ...DRAFTS].map((b) => encodeURIComponent(b)).sort());
551
+ // Nothing deleted: the set-aside worktree still reads its unpushed commit.
552
+ const keptDefault = path.join(kept, encodeURIComponent(DEFAULT_BRANCH));
553
+ expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(unpushed);
554
+ // The configured address has a fresh repository and worktree.
555
+ expect(await fs.readFile(path.join(checkoutOf(DEFAULT_BRANCH), 'marker.txt'), 'utf8')).toBe('replacement');
556
+ expect((await git(repositoryDir(), ['config', '--get', 'remote.origin.url'])).trim()).toBe(replacement);
557
+ });
558
+
559
+ it('sets the repository aside even when the workspaces root is gone, so no later worktree fetches from the old address', async () => {
560
+ await makeRunner([touchDefault]).runAll();
561
+ await fs.rm(workspacesRoot, { recursive: true, force: true });
562
+ const replacement = path.join(root, 'replacement.git');
563
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, replacement]);
564
+ const other = path.join(root, '.seed-replacement');
565
+ await fs.mkdir(other);
566
+ await git(other, ['init', '-b', DEFAULT_BRANCH]);
567
+ await fs.writeFile(path.join(other, 'marker.txt'), 'replacement', 'utf8');
568
+ await git(other, ['add', '-A']);
569
+ await git(other, ['commit', '-m', 'init']);
570
+ await git(other, ['branch', PROTECTED[1]!]);
571
+ await git(other, ['push', '-q', replacement, ...PROTECTED]);
572
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
573
+
574
+ await makeRunner([], { kbRepoUrl: () => replacement }).runAll();
575
+
576
+ const [stamp] = await fs.readdir(path.join(root, 'set-aside'));
577
+ expect(await fs.readdir(path.join(root, 'set-aside', stamp!))).toEqual(['knowledge-base.git']);
578
+ const origin = await git(repositoryDir(), ['config', '--get', 'remote.origin.url']).catch(() => null);
579
+ expect(origin === null || origin.trim() === replacement).toBe(true);
580
+ });
581
+
582
+ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
583
+ 'moves nothing when the workspaces root cannot be read, rather than the repository without its worktrees',
584
+ async () => {
585
+ await makeRunner([touchDefault]).runAll();
586
+ const replacement = path.join(root, 'replacement.git');
587
+ await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, replacement]);
588
+ const other = path.join(root, '.seed-replacement');
589
+ await fs.mkdir(other);
590
+ await git(other, ['init', '-b', DEFAULT_BRANCH]);
591
+ await fs.writeFile(path.join(other, 'marker.txt'), 'replacement', 'utf8');
592
+ await git(other, ['add', '-A']);
593
+ await git(other, ['commit', '-m', 'init']);
594
+ await git(other, ['branch', PROTECTED[1]!]);
595
+ await git(other, ['push', '-q', replacement, ...PROTECTED]);
596
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
597
+ vi.spyOn(console, 'error').mockImplementation(() => {});
598
+
599
+ await fs.chmod(workspacesRoot, 0o000);
600
+ try {
601
+ await makeRunner([], { kbRepoUrl: () => replacement })
602
+ .runAll()
603
+ .catch(() => {});
604
+ } finally {
605
+ await fs.chmod(workspacesRoot, 0o755);
606
+ }
607
+
608
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
609
+ expect((await git(repositoryDir(), ['config', '--get', 'remote.origin.url'])).trim()).toBe(upstream);
610
+ expect((await git(checkoutOf(DEFAULT_BRANCH), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe(DEFAULT_BRANCH);
611
+ },
612
+ );
613
+ });