@bevel-software/platform-core-backend 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- 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/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- 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 +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- 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/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- 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/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.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 +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- 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/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- 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/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.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/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.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/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +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 +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- 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 +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- 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__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- 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 +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- 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/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- 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 +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- 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/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- 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/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- 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 +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- 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 +174 -309
- 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 +26 -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 +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -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 }),
|
|
@@ -16,6 +16,7 @@ import type { ICreatorAccess } from '../../access-model/creator.js';
|
|
|
16
16
|
import { createWorkspaceRoutes } from '../workspace.routes.js';
|
|
17
17
|
import { WorkspaceService } from '../workspace.service.js';
|
|
18
18
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
19
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* A folder exists until someone deletes it, and its placeholder is never shown
|
|
@@ -56,7 +57,7 @@ async function makeHarness(): Promise<Harness> {
|
|
|
56
57
|
const workspaceDir = path.join(root, workspaceId);
|
|
57
58
|
// The inner `.git` lets the service accept the workspace without cloning.
|
|
58
59
|
await fs.mkdir(path.join(workspaceDir, KB, '.git'), { recursive: true });
|
|
59
|
-
const workspaceService =
|
|
60
|
+
const workspaceService = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
60
61
|
await workspaceService.getWorkspacePath(workspaceId);
|
|
61
62
|
|
|
62
63
|
const releaseLock = vi.fn<(...args: unknown[]) => Promise<never>>(async () => undefined as never);
|
|
@@ -335,7 +336,7 @@ describe('WorkspaceService.writeFolderPlaceholder', () => {
|
|
|
335
336
|
const workspaceId = workspaceIdForBranch('placeholders');
|
|
336
337
|
const kbDir = path.join(root, workspaceId, KB);
|
|
337
338
|
await fs.mkdir(path.join(kbDir, '.git'), { recursive: true });
|
|
338
|
-
return { svc:
|
|
339
|
+
return { svc: newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs()), workspaceId, kbDir };
|
|
339
340
|
}
|
|
340
341
|
|
|
341
342
|
it('writes into an empty folder, and leaves a full or a vanished one alone', async () => {
|
|
@@ -378,7 +379,7 @@ describe('WorkspaceService.withFolderTurn', () => {
|
|
|
378
379
|
root = await fs.mkdtemp(path.join(os.tmpdir(), 'folder-turn-'));
|
|
379
380
|
const workspaceId = workspaceIdForBranch('turns');
|
|
380
381
|
await fs.mkdir(path.join(root, workspaceId, KB, '.git'), { recursive: true });
|
|
381
|
-
const svc =
|
|
382
|
+
const svc = newWorkspaceService(root, 'https://example.invalid/kb.git', testKbContext({ kbDirName: KB }), new NodeFs());
|
|
382
383
|
|
|
383
384
|
const events: string[] = [];
|
|
384
385
|
let open!: () => void;
|
|
@@ -6,9 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
8
8
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
9
|
-
import { WorkspaceService } from '../workspace.service.js';
|
|
10
9
|
import { NodeGitRunner } from '../../workflow/git/node-git-runner.js';
|
|
11
10
|
import { gitCredentials } from '../../../shared/git.contract.js';
|
|
11
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
12
12
|
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
14
14
|
|
|
@@ -87,7 +87,7 @@ async function anOldCloneWithNoHelper(branch: string): Promise<string> {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function service(token: string | null) {
|
|
90
|
-
return
|
|
90
|
+
return newWorkspaceService(
|
|
91
91
|
workspacesRoot,
|
|
92
92
|
() => upstream,
|
|
93
93
|
testKbContext({ kbDirName: KB_DIR, branchModel: { defaultBranch: BRANCH, protectedBranches: [BRANCH] } }),
|
|
@@ -4,8 +4,8 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
7
|
-
import { WorkspaceService } from '../workspace.service.js';
|
|
8
7
|
import type { IGitRunner } from '../../../shared/git.contract.js';
|
|
8
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* `ensureRemotesFetched` is one `git fetch --prune origin` for a whole clone,
|
|
@@ -42,7 +42,7 @@ describe('WorkspaceService.ensureRemotesFetched', () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
const service = () =>
|
|
45
|
-
|
|
45
|
+
newWorkspaceService(
|
|
46
46
|
root,
|
|
47
47
|
'https://example.test/kb.git',
|
|
48
48
|
testKbContext(),
|