@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.
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +32 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +138 -16
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +2 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +2 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/git/git.service.d.ts +29 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +177 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +7 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +6 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +36 -3
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +142 -14
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +2 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +184 -22
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +12 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- package/src/test-setup.ts +5 -0
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import AdmZip from 'adm-zip';
|
|
7
7
|
import express from 'express';
|
|
8
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi, afterAll } from 'vitest';
|
|
9
9
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
10
10
|
import type { IWorkflowService } from '@bevel-software/platform-shared';
|
|
11
11
|
import { GIT_INTERNALS_MESSAGE, GitInternalsError } from '../../../shared/domain-errors.js';
|
|
@@ -35,6 +35,12 @@ import { createDiffRoutes } from '../../diff/diff.routes.js';
|
|
|
35
35
|
import { DiffService } from '../../diff/diff.service.js';
|
|
36
36
|
import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
37
37
|
import { WorkspaceService } from '../workspace.service.js';
|
|
38
|
+
import { WorkerPatternMatcher } from '../bounded-matcher.js';
|
|
39
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
40
|
+
|
|
41
|
+
/** The grep matcher this file's harness wires, closed once the file is done. */
|
|
42
|
+
const patternMatcher = new WorkerPatternMatcher();
|
|
43
|
+
afterAll(() => patternMatcher.close());
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
46
|
* Security regression: the repository's internal git folder is unreachable
|
|
@@ -244,7 +250,7 @@ describe('workspace tools refuse the git folder', () => {
|
|
|
244
250
|
|
|
245
251
|
beforeEach(async () => {
|
|
246
252
|
workflow = makeWorkflow();
|
|
247
|
-
const service =
|
|
253
|
+
const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
248
254
|
unzipSpy = vi.spyOn(service, 'unzipFile');
|
|
249
255
|
bootstrappedSpy = vi.spyOn(service, 'hasBootstrappedWorkspace');
|
|
250
256
|
const lockingFs = new LockingFilesystem(
|
|
@@ -282,6 +288,15 @@ describe('workspace tools refuse the git folder', () => {
|
|
|
282
288
|
{ recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
283
289
|
new RoutineWritePolicyService(),
|
|
284
290
|
{} as never,
|
|
291
|
+
// Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
|
|
292
|
+
undefined,
|
|
293
|
+
undefined,
|
|
294
|
+
undefined,
|
|
295
|
+
undefined,
|
|
296
|
+
undefined,
|
|
297
|
+
undefined,
|
|
298
|
+
undefined,
|
|
299
|
+
patternMatcher,
|
|
285
300
|
);
|
|
286
301
|
app.use('/api', router);
|
|
287
302
|
({ server, baseUrl } = await listen(app));
|
|
@@ -419,7 +434,7 @@ describe('workspace routes refuse the git folder', () => {
|
|
|
419
434
|
|
|
420
435
|
beforeEach(async () => {
|
|
421
436
|
workflow = makeWorkflow();
|
|
422
|
-
service =
|
|
437
|
+
service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
423
438
|
const app = express();
|
|
424
439
|
app.use(express.json());
|
|
425
440
|
app.use('/api', (req, _res, next) => {
|
|
@@ -566,7 +581,7 @@ describe('WorkspaceService refuses the git folder on its own', () => {
|
|
|
566
581
|
let service: WorkspaceService;
|
|
567
582
|
|
|
568
583
|
beforeEach(() => {
|
|
569
|
-
service =
|
|
584
|
+
service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
570
585
|
});
|
|
571
586
|
|
|
572
587
|
afterEach(async () => {
|
|
@@ -623,7 +638,7 @@ describe('WorkspaceService refuses the git folder on its own', () => {
|
|
|
623
638
|
return { stdout: '.git\n', stderr: '', code: 0 };
|
|
624
639
|
}),
|
|
625
640
|
};
|
|
626
|
-
const refService =
|
|
641
|
+
const refService = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs(), gitRunner as never);
|
|
627
642
|
|
|
628
643
|
for (const [form, raw] of Object.entries(fileForms('config'))) {
|
|
629
644
|
if (!form.startsWith('symlinked')) continue;
|
|
@@ -679,7 +694,7 @@ describe('the route guard on its own', () => {
|
|
|
679
694
|
beforeEach(async () => {
|
|
680
695
|
reached = 0;
|
|
681
696
|
userId = USER.id;
|
|
682
|
-
const service =
|
|
697
|
+
const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
683
698
|
const app = express();
|
|
684
699
|
// The server's own order: the guard ahead of the body parser, then again
|
|
685
700
|
// on the parsed body, then again once the caller is known.
|
|
@@ -779,7 +794,7 @@ describe('DiffService refuses the git folder on its own', () => {
|
|
|
779
794
|
let diffService: DiffService;
|
|
780
795
|
|
|
781
796
|
beforeEach(() => {
|
|
782
|
-
const service =
|
|
797
|
+
const service = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
783
798
|
diffService = new DiffService(service, new WorkspaceMutex(), root, join(root, 'backups'), KB, new NodeFs());
|
|
784
799
|
});
|
|
785
800
|
|
|
@@ -0,0 +1,481 @@
|
|
|
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 { KbRepository, repositoryDirFor } from '../kb-repository.js';
|
|
8
|
+
import { fetchInstant } from '../fetch-instant.js';
|
|
9
|
+
import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
|
|
10
|
+
import type { IGitRunner } from '../../../shared/git.contract.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The repository's own contracts, over real git: a fetch from before a
|
|
14
|
+
* caller's instant is not taken for one after it, and what the worktree list
|
|
15
|
+
* and the shared config are written by runs as the administrator.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
const TARGET = 'current-company-state';
|
|
20
|
+
|
|
21
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
22
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
23
|
+
cwd,
|
|
24
|
+
env: {
|
|
25
|
+
...process.env,
|
|
26
|
+
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
|
|
27
|
+
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
return stdout.toString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let root: string;
|
|
34
|
+
let upstream: string;
|
|
35
|
+
let workspacesRoot: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-kb-repository-'));
|
|
39
|
+
workspacesRoot = path.join(root, 'workspaces');
|
|
40
|
+
await fs.mkdir(workspacesRoot, { recursive: true });
|
|
41
|
+
upstream = path.join(root, 'upstream.git');
|
|
42
|
+
await git(root, ['init', '--bare', '-b', TARGET, upstream]);
|
|
43
|
+
const seed = path.join(root, '.seed');
|
|
44
|
+
await fs.mkdir(seed);
|
|
45
|
+
await git(seed, ['init', '-b', TARGET]);
|
|
46
|
+
await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
|
|
47
|
+
await git(seed, ['add', '-A']);
|
|
48
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
49
|
+
await git(seed, ['branch', 'alice/a']);
|
|
50
|
+
await git(seed, ['branch', 'bob/b']);
|
|
51
|
+
await git(seed, ['push', '-q', upstream, TARGET, 'alice/a', 'bob/b']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The repository, noting whether its administrator's section is held while git runs. */
|
|
59
|
+
class RecordingRepository extends KbRepository {
|
|
60
|
+
depth = 0;
|
|
61
|
+
override exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
62
|
+
return super.exclusive(async () => {
|
|
63
|
+
this.depth += 1;
|
|
64
|
+
try { return await fn(); } finally { this.depth -= 1; }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isOriginFetch = (args: string[]) => args.includes('fetch') && args.at(-1) === 'origin';
|
|
70
|
+
|
|
71
|
+
describe('KbRepository.fetch', () => {
|
|
72
|
+
it('does not join a fetch that started before the caller\'s instant: it waits, and fetches again', async () => {
|
|
73
|
+
const inner = new NodeGitRunner();
|
|
74
|
+
let hold: Promise<void> | null = null;
|
|
75
|
+
let fetched: (() => void) | null = null;
|
|
76
|
+
let fetches = 0;
|
|
77
|
+
const gitRunner = {
|
|
78
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
79
|
+
credentials: inner.credentials,
|
|
80
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
81
|
+
const out = await inner.run(cwd, args, opts as never);
|
|
82
|
+
if (isOriginFetch(args)) {
|
|
83
|
+
fetches += 1;
|
|
84
|
+
// The fetch has read the remote; it is held before it answers.
|
|
85
|
+
fetched?.();
|
|
86
|
+
if (hold) await hold;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
},
|
|
90
|
+
} as unknown as IGitRunner;
|
|
91
|
+
const repository = new KbRepository({
|
|
92
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
93
|
+
kbRepoUrl: () => upstream,
|
|
94
|
+
gitRunner,
|
|
95
|
+
});
|
|
96
|
+
await repository.ensure();
|
|
97
|
+
fetches = 0;
|
|
98
|
+
|
|
99
|
+
let release!: () => void;
|
|
100
|
+
hold = new Promise((r) => { release = r; });
|
|
101
|
+
const readRemote = new Promise<void>((r) => { fetched = r; });
|
|
102
|
+
const older = repository.fetch({ maxAgeMs: 0, force: true });
|
|
103
|
+
await readRemote;
|
|
104
|
+
hold = null;
|
|
105
|
+
fetched = null;
|
|
106
|
+
// Deleted on the remote after the older fetch read it.
|
|
107
|
+
await git(upstream, ['update-ref', '-d', 'refs/heads/bob/b']);
|
|
108
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
109
|
+
const since = Date.now();
|
|
110
|
+
|
|
111
|
+
const newer = repository.fetch({ maxAgeMs: 60_000, fetchedSince: since });
|
|
112
|
+
release();
|
|
113
|
+
|
|
114
|
+
expect(await older).toBe(true);
|
|
115
|
+
expect(await newer).toBe(true);
|
|
116
|
+
expect(fetches).toBe(2);
|
|
117
|
+
await expect(
|
|
118
|
+
git(repository.dir, ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/bob/b']),
|
|
119
|
+
).rejects.toThrow();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('joins a fetch in flight that started after the caller\'s instant', async () => {
|
|
123
|
+
const inner = new NodeGitRunner();
|
|
124
|
+
let fetches = 0;
|
|
125
|
+
const gitRunner = {
|
|
126
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
127
|
+
credentials: inner.credentials,
|
|
128
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
129
|
+
if (isOriginFetch(args)) fetches += 1;
|
|
130
|
+
return inner.run(cwd, args, opts as never);
|
|
131
|
+
},
|
|
132
|
+
} as unknown as IGitRunner;
|
|
133
|
+
const repository = new KbRepository({
|
|
134
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
135
|
+
kbRepoUrl: () => upstream,
|
|
136
|
+
gitRunner,
|
|
137
|
+
});
|
|
138
|
+
await repository.ensure();
|
|
139
|
+
fetches = 0;
|
|
140
|
+
|
|
141
|
+
const since = Date.now();
|
|
142
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
143
|
+
const [a, b] = await Promise.all([
|
|
144
|
+
repository.fetch({ maxAgeMs: 0, force: true }),
|
|
145
|
+
repository.fetch({ maxAgeMs: 60_000, fetchedSince: since }),
|
|
146
|
+
]);
|
|
147
|
+
expect([a, b]).toEqual([true, true]);
|
|
148
|
+
expect(fetches).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('KbRepository.fetch, an instant in the same millisecond', () => {
|
|
153
|
+
/** A repository whose fetches of origin are counted and held while `gate` is set; with `failFirst`, the first after it is made fails. */
|
|
154
|
+
async function gatedRepository(failFirst = false) {
|
|
155
|
+
const inner = new NodeGitRunner();
|
|
156
|
+
const state = { fetches: 0, gate: null as Promise<void> | null, failFirst: false };
|
|
157
|
+
const gitRunner = {
|
|
158
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
159
|
+
credentials: inner.credentials,
|
|
160
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
161
|
+
if (!isOriginFetch(args)) return inner.run(cwd, args, opts as never);
|
|
162
|
+
state.fetches += 1;
|
|
163
|
+
const first = state.fetches === 1;
|
|
164
|
+
if (state.gate) await state.gate;
|
|
165
|
+
if (state.failFirst && first) throw new Error('fatal: unable to access the remote');
|
|
166
|
+
return inner.run(cwd, args, opts as never);
|
|
167
|
+
},
|
|
168
|
+
} as unknown as IGitRunner;
|
|
169
|
+
const repository = new KbRepository({
|
|
170
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
171
|
+
kbRepoUrl: () => upstream,
|
|
172
|
+
gitRunner,
|
|
173
|
+
});
|
|
174
|
+
await repository.ensure();
|
|
175
|
+
state.fetches = 0;
|
|
176
|
+
state.failFirst = failFirst;
|
|
177
|
+
return { repository, state };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it('reads instants that never compare equal, however close together', () => {
|
|
181
|
+
const readings = Array.from({ length: 1000 }, () => fetchInstant());
|
|
182
|
+
for (let i = 1; i < readings.length; i++) expect(readings[i]).toBeGreaterThan(readings[i - 1]!);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not join a fetch that started just before the caller\'s instant, in the same millisecond', async () => {
|
|
186
|
+
const { repository, state } = await gatedRepository();
|
|
187
|
+
let release!: () => void;
|
|
188
|
+
state.gate = new Promise((r) => { release = r; });
|
|
189
|
+
const older = repository.fetch({ maxAgeMs: 0, force: true });
|
|
190
|
+
// Read right after the older fetch started: the same millisecond, as a rule.
|
|
191
|
+
const since = fetchInstant();
|
|
192
|
+
const newer = repository.fetch({ maxAgeMs: 60_000, fetchedSince: since });
|
|
193
|
+
state.gate = null;
|
|
194
|
+
release();
|
|
195
|
+
|
|
196
|
+
expect(await older).toBe(true);
|
|
197
|
+
expect(await newer).toBe(true);
|
|
198
|
+
expect(state.fetches).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('fetches after the instant even when the older fetch it waited for failed within the backoff', async () => {
|
|
202
|
+
const { repository, state } = await gatedRepository(true);
|
|
203
|
+
let release!: () => void;
|
|
204
|
+
state.gate = new Promise((r) => { release = r; });
|
|
205
|
+
const older = repository.fetch({ maxAgeMs: 0, force: true });
|
|
206
|
+
const since = fetchInstant();
|
|
207
|
+
const newer = repository.fetch({ maxAgeMs: 60_000, fetchedSince: since, failureBackoffMs: 60_000 });
|
|
208
|
+
state.gate = null;
|
|
209
|
+
release();
|
|
210
|
+
|
|
211
|
+
expect(await older).toBe(false);
|
|
212
|
+
expect(await newer).toBe(true);
|
|
213
|
+
expect(state.fetches).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('KbRepository.exists', () => {
|
|
218
|
+
it('is false only for an absent marker: a repository that was never made, or a making cut short', async () => {
|
|
219
|
+
const repository = new KbRepository({
|
|
220
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
221
|
+
kbRepoUrl: () => upstream,
|
|
222
|
+
gitRunner: new NodeGitRunner(),
|
|
223
|
+
});
|
|
224
|
+
expect(await repository.exists()).toBe(false);
|
|
225
|
+
await repository.ensure();
|
|
226
|
+
expect(await repository.exists()).toBe(true);
|
|
227
|
+
await fs.rm(path.join(repository.dir, 'hexis-repository-ready'));
|
|
228
|
+
expect(await repository.exists()).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// A marker that cannot be read is not an absent one. Not on Windows (no
|
|
232
|
+
// POSIX modes) or as root (who reads it anyway).
|
|
233
|
+
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
|
234
|
+
'throws when the marker cannot be read, and ensure() then leaves the repository and its worktrees alone',
|
|
235
|
+
async () => {
|
|
236
|
+
const repository = new KbRepository({
|
|
237
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
238
|
+
kbRepoUrl: () => upstream,
|
|
239
|
+
gitRunner: new NodeGitRunner(),
|
|
240
|
+
});
|
|
241
|
+
const checkoutDir = path.join(workspacesRoot, encodeURIComponent('alice/a'), 'knowledge-base');
|
|
242
|
+
await repository.addWorktree('alice/a', checkoutDir);
|
|
243
|
+
const tip = (await git(repository.dir, ['rev-parse', 'refs/heads/alice/a'])).trim();
|
|
244
|
+
await fs.chmod(repository.dir, 0o000);
|
|
245
|
+
try {
|
|
246
|
+
await expect(repository.exists()).rejects.toThrow(/EACCES/);
|
|
247
|
+
await expect(repository.ensure()).rejects.toThrow(/EACCES/);
|
|
248
|
+
await expect(repository.fetch({ maxAgeMs: 0, force: true })).resolves.toBe(false);
|
|
249
|
+
} finally {
|
|
250
|
+
await fs.chmod(repository.dir, 0o755);
|
|
251
|
+
}
|
|
252
|
+
// Whole: nothing was cleared and made again.
|
|
253
|
+
expect(await repository.exists()).toBe(true);
|
|
254
|
+
expect((await git(repository.dir, ['rev-parse', 'refs/heads/alice/a'])).trim()).toBe(tip);
|
|
255
|
+
expect((await git(checkoutDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('alice/a');
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('KbRepository.ensure', () => {
|
|
261
|
+
it('does not record the credential helper as stamped when its write failed, so refreshCredentials() writes it', async () => {
|
|
262
|
+
const inner = new NodeGitRunner();
|
|
263
|
+
let failHelper = true;
|
|
264
|
+
const helperWrites: string[][] = [];
|
|
265
|
+
const gitRunner = {
|
|
266
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
267
|
+
credentials: { username: () => 'bevel', token: () => 'a-token' },
|
|
268
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
269
|
+
if (args[0] === 'config' && args.includes('credential.helper')) {
|
|
270
|
+
helperWrites.push(args);
|
|
271
|
+
if (failHelper) throw new Error('could not lock config file config: File exists');
|
|
272
|
+
}
|
|
273
|
+
return inner.run(cwd, args, opts as never);
|
|
274
|
+
},
|
|
275
|
+
} as unknown as IGitRunner;
|
|
276
|
+
const repository = new KbRepository({
|
|
277
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
278
|
+
kbRepoUrl: () => upstream,
|
|
279
|
+
gitRunner,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await repository.ensure();
|
|
283
|
+
expect(helperWrites).toHaveLength(1);
|
|
284
|
+
expect(await repository.exists()).toBe(true);
|
|
285
|
+
|
|
286
|
+
failHelper = false;
|
|
287
|
+
await repository.refreshCredentials();
|
|
288
|
+
expect(helperWrites).toHaveLength(2);
|
|
289
|
+
// Stamped now: the next refresh costs no git.
|
|
290
|
+
await repository.refreshCredentials();
|
|
291
|
+
expect(helperWrites).toHaveLength(2);
|
|
292
|
+
expect((await git(repository.dir, ['config', '--get-all', 'credential.helper'])).trim()).toContain('GITHUB_TOKEN');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('KbRepository.forgetWorktrees', () => {
|
|
297
|
+
it('prunes and deletes the branches as the administrator, and keeps the objects', async () => {
|
|
298
|
+
const inner = new NodeGitRunner();
|
|
299
|
+
const calls: { args: string[]; held: boolean }[] = [];
|
|
300
|
+
const gitRunner = {
|
|
301
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
302
|
+
credentials: inner.credentials,
|
|
303
|
+
run: (cwd: string, args: string[], opts?: object) => {
|
|
304
|
+
// `repository` is read at call time, once it exists below.
|
|
305
|
+
calls.push({ args, held: repository.depth > 0 });
|
|
306
|
+
return inner.run(cwd, args, opts as never);
|
|
307
|
+
},
|
|
308
|
+
} as unknown as IGitRunner;
|
|
309
|
+
const repository: RecordingRepository = new RecordingRepository({
|
|
310
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
311
|
+
kbRepoUrl: () => upstream,
|
|
312
|
+
gitRunner,
|
|
313
|
+
});
|
|
314
|
+
const checkout = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
|
|
315
|
+
await repository.addWorktree('alice/a', checkout('alice/a'));
|
|
316
|
+
await repository.addWorktree('bob/b', checkout('bob/b'));
|
|
317
|
+
const aliceTip = (await git(repository.dir, ['rev-parse', 'refs/heads/alice/a'])).trim();
|
|
318
|
+
// Deleted by hand, as the orphan sweep deletes it.
|
|
319
|
+
await fs.rm(path.dirname(checkout('alice/a')), { recursive: true, force: true });
|
|
320
|
+
calls.length = 0;
|
|
321
|
+
|
|
322
|
+
await repository.forgetWorktrees(['alice/a']);
|
|
323
|
+
|
|
324
|
+
const written = calls.filter(({ args }) =>
|
|
325
|
+
(args.includes('worktree') && args.includes('prune')) || (args.includes('branch') && args.includes('-D')));
|
|
326
|
+
expect(written.map(({ args }) => args.filter((a) => a === 'prune' || a === '-D'))).toEqual([['prune'], ['-D']]);
|
|
327
|
+
for (const c of written) expect(c.held, c.args.join(' ')).toBe(true);
|
|
328
|
+
await expect(git(repository.dir, ['rev-parse', '--verify', '--quiet', 'refs/heads/alice/a'])).rejects.toThrow();
|
|
329
|
+
expect((await git(repository.dir, ['cat-file', '-t', aliceTip])).trim()).toBe('commit');
|
|
330
|
+
expect((await git(checkout('bob/b'), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('bob/b');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('KbRepository.addWorktree', () => {
|
|
335
|
+
it('reads the local ref inside its section: a removal queued before it does not take the ref it uses', async () => {
|
|
336
|
+
const inner = new NodeGitRunner();
|
|
337
|
+
/** How many `exclusive` sections have been queued. */
|
|
338
|
+
let queued = 0;
|
|
339
|
+
let onQueued = (): void => undefined;
|
|
340
|
+
class QueueWatchingRepository extends KbRepository {
|
|
341
|
+
override exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
342
|
+
const section = super.exclusive(fn);
|
|
343
|
+
queued += 1;
|
|
344
|
+
onQueued();
|
|
345
|
+
return section;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const queuedCount = (n: number) =>
|
|
349
|
+
new Promise<void>((resolve) => {
|
|
350
|
+
onQueued = () => { if (queued >= n) resolve(); };
|
|
351
|
+
onQueued();
|
|
352
|
+
});
|
|
353
|
+
let releaseGate = (): void => undefined;
|
|
354
|
+
let removing: Promise<void> | null = null;
|
|
355
|
+
const gitRunner = {
|
|
356
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
357
|
+
credentials: inner.credentials,
|
|
358
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
359
|
+
const result = await inner.run(cwd, args, opts as never);
|
|
360
|
+
if (removing === null && args.includes('fetch') && args.includes('+refs/heads/alice/a:refs/remotes/origin/alice/a')) {
|
|
361
|
+
// The add has asked the remote and has not reached its section:
|
|
362
|
+
// the administrator is busy, and the workspace's removal queues
|
|
363
|
+
// first. The add's section comes after it.
|
|
364
|
+
const base = queued;
|
|
365
|
+
void repository.exclusive(() => new Promise<void>((resolve) => { releaseGate = resolve; }));
|
|
366
|
+
removing = repository.removeWorktree(path.join(workspacesRoot, 'gone', 'knowledge-base'), 'alice/a');
|
|
367
|
+
await queuedCount(base + 2);
|
|
368
|
+
void queuedCount(base + 3).then(() => releaseGate());
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
},
|
|
372
|
+
} as unknown as IGitRunner;
|
|
373
|
+
const repository: QueueWatchingRepository = new QueueWatchingRepository({
|
|
374
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
375
|
+
kbRepoUrl: () => upstream,
|
|
376
|
+
gitRunner,
|
|
377
|
+
});
|
|
378
|
+
await repository.ensure();
|
|
379
|
+
// A local ref with no worktree of it (a workspace deleted with its ref
|
|
380
|
+
// still to go), behind the remote, which has moved on since: the removal
|
|
381
|
+
// deletes it, and the add would otherwise move a ref that is gone.
|
|
382
|
+
await git(repository.dir, ['update-ref', 'refs/heads/alice/a', 'refs/remotes/origin/alice/a']);
|
|
383
|
+
const seed = path.join(root, '.seed');
|
|
384
|
+
await git(seed, ['checkout', '-q', 'alice/a']);
|
|
385
|
+
await fs.writeFile(path.join(seed, 'later.md'), 'later\n');
|
|
386
|
+
await git(seed, ['add', '-A']);
|
|
387
|
+
await git(seed, ['commit', '-m', 'later']);
|
|
388
|
+
await git(seed, ['push', '-q', upstream, 'alice/a']);
|
|
389
|
+
const remoteTip = (await git(seed, ['rev-parse', 'HEAD'])).trim();
|
|
390
|
+
const checkoutDir = path.join(workspacesRoot, encodeURIComponent('alice/a'), 'knowledge-base');
|
|
391
|
+
|
|
392
|
+
await repository.addWorktree('alice/a', checkoutDir);
|
|
393
|
+
await removing;
|
|
394
|
+
|
|
395
|
+
expect((await git(checkoutDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('alice/a');
|
|
396
|
+
expect((await git(checkoutDir, ['rev-parse', 'HEAD'])).trim()).toBe(remoteTip);
|
|
397
|
+
expect((await git(repository.dir, ['config', '--get', 'branch.alice/a.merge'])).trim()).toBe('refs/heads/alice/a');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe('KbRepository.importLocalRefs', () => {
|
|
402
|
+
it('throws when the stash log cannot be read, rather than keep only its latest entry', async () => {
|
|
403
|
+
const inner = new NodeGitRunner();
|
|
404
|
+
const gitRunner = {
|
|
405
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
406
|
+
credentials: inner.credentials,
|
|
407
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
408
|
+
if (args[0] === 'reflog') throw new Error('git reflog timed out');
|
|
409
|
+
return inner.run(cwd, args, opts as never);
|
|
410
|
+
},
|
|
411
|
+
} as unknown as IGitRunner;
|
|
412
|
+
const repository = new KbRepository({
|
|
413
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
414
|
+
kbRepoUrl: () => upstream,
|
|
415
|
+
gitRunner,
|
|
416
|
+
});
|
|
417
|
+
const clone = path.join(root, 'clone');
|
|
418
|
+
await git(root, ['clone', '-q', '-b', 'alice/a', upstream, clone]);
|
|
419
|
+
for (const text of ['first', 'second']) {
|
|
420
|
+
await fs.writeFile(path.join(clone, 'draft.md'), text);
|
|
421
|
+
await git(clone, ['stash', 'push', '-q', '-u', '-m', text]);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await expect(repository.importLocalRefs(clone, 'alice/a', 'alice%2Fa')).rejects.toThrow(/reflog timed out/);
|
|
425
|
+
// Nothing was taken for the whole stash.
|
|
426
|
+
await expect(git(repository.dir, ['rev-parse', '--verify', '--quiet', 'refs/kb-converted/alice%2Fa/stash/0']))
|
|
427
|
+
.rejects.toThrow();
|
|
428
|
+
expect((await git(clone, ['stash', 'list'])).trim().split('\n')).toHaveLength(2);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/** A clone of alice/a with two stash entries, and the repository to import it into. */
|
|
432
|
+
async function cloneWithTwoStashes(): Promise<{ repository: KbRepository; clone: string; stashes: string[] }> {
|
|
433
|
+
const repository = new KbRepository({
|
|
434
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
435
|
+
kbRepoUrl: () => upstream,
|
|
436
|
+
gitRunner: new NodeGitRunner(),
|
|
437
|
+
});
|
|
438
|
+
const clone = path.join(root, 'clone');
|
|
439
|
+
await git(root, ['clone', '-q', '-b', 'alice/a', upstream, clone]);
|
|
440
|
+
for (const text of ['first', 'second']) {
|
|
441
|
+
await fs.writeFile(path.join(clone, 'draft.md'), text);
|
|
442
|
+
await git(clone, ['stash', 'push', '-q', '-u', '-m', text]);
|
|
443
|
+
}
|
|
444
|
+
const stashes = (await git(clone, ['reflog', 'show', '--format=%H', 'refs/stash'])).trim().split('\n');
|
|
445
|
+
return { repository, clone, stashes };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Git reads a log it cannot open as an empty one and exits 0. Not on
|
|
449
|
+
// Windows (no POSIX modes) or as root (who reads it anyway).
|
|
450
|
+
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
|
451
|
+
'throws when the stash log is unreadable, which git itself reads as empty',
|
|
452
|
+
async () => {
|
|
453
|
+
const { repository, clone } = await cloneWithTwoStashes();
|
|
454
|
+
const logFile = path.join(clone, '.git', 'logs', 'refs', 'stash');
|
|
455
|
+
await fs.chmod(logFile, 0o000);
|
|
456
|
+
try {
|
|
457
|
+
expect((await git(clone, ['reflog', 'show', '--format=%H', 'refs/stash'])).trim()).toBe('');
|
|
458
|
+
await expect(repository.importLocalRefs(clone, 'alice/a', 'alice%2Fa')).rejects.toThrow(/EACCES/);
|
|
459
|
+
} finally {
|
|
460
|
+
await fs.chmod(logFile, 0o644);
|
|
461
|
+
}
|
|
462
|
+
await expect(git(repository.dir, ['rev-parse', '--verify', '--quiet', 'refs/kb-converted/alice%2Fa/stash/0']))
|
|
463
|
+
.rejects.toThrow();
|
|
464
|
+
expect((await git(clone, ['stash', 'list'])).trim().split('\n')).toHaveLength(2);
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
it('keeps every stash entry, and only the tip of a stash with no log', async () => {
|
|
469
|
+
const { repository, clone, stashes } = await cloneWithTwoStashes();
|
|
470
|
+
const held = (ref: string) => git(repository.dir, ['rev-parse', ref]).then((o) => o.trim());
|
|
471
|
+
|
|
472
|
+
await repository.importLocalRefs(clone, 'alice/a', 'alice%2Fa');
|
|
473
|
+
expect(await held('refs/kb-converted/alice%2Fa/stash/0')).toBe(stashes[0]);
|
|
474
|
+
expect(await held('refs/kb-converted/alice%2Fa/stash/1')).toBe(stashes[1]);
|
|
475
|
+
|
|
476
|
+
await fs.rm(path.join(clone, '.git', 'logs', 'refs', 'stash'));
|
|
477
|
+
await repository.importLocalRefs(clone, 'alice/a', 'bob%2Fb');
|
|
478
|
+
expect(await held('refs/kb-converted/bob%2Fb/stash/0')).toBe(stashes[0]);
|
|
479
|
+
await expect(held('refs/kb-converted/bob%2Fb/stash/1')).rejects.toThrow();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -21,6 +21,11 @@ import { AccessControlService } from '../../access/access-control.service.js';
|
|
|
21
21
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
22
22
|
import { BRANCH_REQUIRED_MESSAGE } from '../../../shared/domain-errors.js';
|
|
23
23
|
import { callLine, compileCheck, exampleArguments } from '@bevel-software/platform-mcp-core';
|
|
24
|
+
import { WorkerPatternMatcher } from '../bounded-matcher.js';
|
|
25
|
+
|
|
26
|
+
/** The grep matcher this file's harness wires, closed once the file is done. */
|
|
27
|
+
const patternMatcher = new WorkerPatternMatcher();
|
|
28
|
+
afterAll(() => patternMatcher.close());
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* The REST route a script, a runner or a CI job calls with a connection key —
|
|
@@ -92,6 +97,15 @@ beforeAll(async () => {
|
|
|
92
97
|
{ recoveryBotEmail: 'recovery-bot@bevel.local', hooks: new WorkflowHooks(), notes: new ToolDescriptionNotes() },
|
|
93
98
|
new RoutineWritePolicyService(),
|
|
94
99
|
{} as never,
|
|
100
|
+
// Skill check, change gate, uploads, guide, downloads, starter packs, disk: not this harness's business.
|
|
101
|
+
undefined,
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
undefined,
|
|
105
|
+
undefined,
|
|
106
|
+
undefined,
|
|
107
|
+
undefined,
|
|
108
|
+
patternMatcher,
|
|
95
109
|
);
|
|
96
110
|
app.use('/api', router);
|
|
97
111
|
server = await new Promise<HttpServer>((r) => {
|
|
@@ -8,7 +8,6 @@ describe('RoutineWritePolicyService', () => {
|
|
|
8
8
|
it('allows any write when the session has no restriction', () => {
|
|
9
9
|
const policy = new RoutineWritePolicyService();
|
|
10
10
|
expect(() => policy.assertPathWritable(SID, 'knowledge-base/KnowledgeBase/Product/Knowledge/Foo.md')).not.toThrow();
|
|
11
|
-
expect(() => policy.assertUnrestricted(SID)).not.toThrow();
|
|
12
11
|
});
|
|
13
12
|
|
|
14
13
|
it('allows only the restricted extensions and blocks everything else with a 403', () => {
|
|
@@ -38,15 +37,6 @@ describe('RoutineWritePolicyService', () => {
|
|
|
38
37
|
// A different (or absent) session id is unaffected by SID's restriction.
|
|
39
38
|
expect(() => policy.assertPathWritable(undefined, 'anything.md')).not.toThrow();
|
|
40
39
|
expect(() => policy.assertPathWritable('other-session', 'anything.md')).not.toThrow();
|
|
41
|
-
expect(() => policy.assertUnrestricted(undefined)).not.toThrow();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('blocks the pathless shell tool for a restricted session', () => {
|
|
45
|
-
const policy = new RoutineWritePolicyService();
|
|
46
|
-
policy.restrictToExtensions(SID, ['.html']);
|
|
47
|
-
const err = getThrown(() => policy.assertUnrestricted(SID));
|
|
48
|
-
expect(err).toBeInstanceOf(ToolError);
|
|
49
|
-
expect((err as ToolError).status).toBe(403);
|
|
50
40
|
});
|
|
51
41
|
|
|
52
42
|
it('clear() lifts the restriction', () => {
|
|
@@ -54,7 +44,6 @@ describe('RoutineWritePolicyService', () => {
|
|
|
54
44
|
policy.restrictToExtensions(SID, ['.html']);
|
|
55
45
|
policy.clear(SID);
|
|
56
46
|
expect(() => policy.assertPathWritable(SID, 'foo.md')).not.toThrow();
|
|
57
|
-
expect(() => policy.assertUnrestricted(SID)).not.toThrow();
|
|
58
47
|
});
|
|
59
48
|
});
|
|
60
49
|
|
|
@@ -16,8 +16,8 @@ import type { AuthService } from '../../auth/auth.service.js';
|
|
|
16
16
|
import type { IAdminAccessService } from '../../admin/admin.interface.js';
|
|
17
17
|
import type { ICreatorAccess } from '../../access-model/creator.js';
|
|
18
18
|
import { createWorkspaceRoutes } from '../workspace.routes.js';
|
|
19
|
-
import { WorkspaceService } from '../workspace.service.js';
|
|
20
19
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
20
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Nothing the platform accepts resolves outside the repository.
|
|
@@ -56,7 +56,7 @@ async function makeHarness(): Promise<Harness> {
|
|
|
56
56
|
const workspaceDir = path.join(root, workspaceId);
|
|
57
57
|
// The inner `.git` lets the service accept the workspace without cloning.
|
|
58
58
|
await fs.mkdir(path.join(workspaceDir, KB, '.git'), { recursive: true });
|
|
59
|
-
const workspaceService =
|
|
59
|
+
const workspaceService = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
60
60
|
await workspaceService.getWorkspacePath(workspaceId);
|
|
61
61
|
|
|
62
62
|
const asked: string[] = [];
|
|
@@ -15,7 +15,7 @@ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
|
15
15
|
import type { AuthService } from '../../auth/auth.service.js';
|
|
16
16
|
import { AccessControlService } from '../../access/access-control.service.js';
|
|
17
17
|
import { createWorkspaceRoutes } from '../workspace.routes.js';
|
|
18
|
-
import {
|
|
18
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The folder download over the REAL resolver and the REAL zip builder.
|
|
@@ -89,7 +89,7 @@ describe('GET /folder/zip judges every file it packs (real resolver, real zip)',
|
|
|
89
89
|
await fs.writeFile(abs, contents);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
const workspaceService =
|
|
92
|
+
const workspaceService = newWorkspaceService(
|
|
93
93
|
root,
|
|
94
94
|
'https://example.invalid/kb.git',
|
|
95
95
|
testKbContext({ kbDirName: KB }),
|