@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,284 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { testKbContext } from '../../../../__tests__/kb-context.js';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
9
|
+
import { NodeFs } from '../../../kb-fs/node-fs.js';
|
|
10
|
+
import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
|
|
11
|
+
import { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
12
|
+
import { KbRepository, repositoryDirFor } from '../../../workspace/kb-repository.js';
|
|
13
|
+
import { WorkflowHooks } from '../../workflow-hooks.js';
|
|
14
|
+
import { GitService } from '../git.service.js';
|
|
15
|
+
import { NodeGitRunner } from '../node-git-runner.js';
|
|
16
|
+
import { workspaceIdForBranch } from '../../../../shared/workspace-id.js';
|
|
17
|
+
import { newWorkspaceService } from '../../../../__tests__/workspace-service.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The git layer over a knowledge base of WORKTREES of one repository: the
|
|
21
|
+
* same answers as over clones of the same remote, two worktrees committing at
|
|
22
|
+
* once, and a branch deleted while its own worktree is open. Real git.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
const TARGET = 'current-company-state';
|
|
27
|
+
const ALICE: AuthUser = { id: 'u-alice', email: 'alice@example.com', name: 'Alice' };
|
|
28
|
+
|
|
29
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
30
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
31
|
+
cwd,
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
|
|
35
|
+
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return stdout.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let root: string;
|
|
42
|
+
let upstream: string;
|
|
43
|
+
let workspacesRoot: string;
|
|
44
|
+
let workspaces: WorkspaceService;
|
|
45
|
+
let gitService: GitService;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-worktrees-'));
|
|
49
|
+
workspacesRoot = path.join(root, 'workspaces');
|
|
50
|
+
await fs.mkdir(workspacesRoot, { recursive: true });
|
|
51
|
+
upstream = path.join(root, 'upstream.git');
|
|
52
|
+
await git(root, ['init', '--bare', '-b', TARGET, upstream]);
|
|
53
|
+
const seed = path.join(root, '.seed');
|
|
54
|
+
await fs.mkdir(seed);
|
|
55
|
+
await git(seed, ['init', '-b', TARGET]);
|
|
56
|
+
await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
|
|
57
|
+
await git(seed, ['add', '-A']);
|
|
58
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
59
|
+
for (const draft of ['alice/a', 'bob/b']) {
|
|
60
|
+
await git(seed, ['checkout', '-q', '-b', draft, TARGET]);
|
|
61
|
+
await fs.writeFile(path.join(seed, `${draft.split('/')[0]}.md`), `${draft}\n`);
|
|
62
|
+
await git(seed, ['add', '-A']);
|
|
63
|
+
await git(seed, ['commit', '-m', `on ${draft}`]);
|
|
64
|
+
}
|
|
65
|
+
await git(seed, ['push', '-q', upstream, TARGET, 'alice/a', 'bob/b']);
|
|
66
|
+
|
|
67
|
+
const mutex = new WorkspaceMutex();
|
|
68
|
+
const runner = new NodeGitRunner();
|
|
69
|
+
const repository = new KbRepository({
|
|
70
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
71
|
+
kbRepoUrl: () => upstream,
|
|
72
|
+
gitRunner: runner,
|
|
73
|
+
});
|
|
74
|
+
workspaces = newWorkspaceService(
|
|
75
|
+
workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
|
|
76
|
+
);
|
|
77
|
+
gitService = new GitService(workspaces, new WorkflowHooks(), testKbContext(), mutex, null, runner);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const id = (branch: string) => workspaceIdForBranch(branch);
|
|
85
|
+
const checkoutOf = (branch: string) => path.join(workspacesRoot, id(branch), 'knowledge-base');
|
|
86
|
+
|
|
87
|
+
describe('GitService over worktrees', () => {
|
|
88
|
+
it('lands two commits requested at the same time in two worktrees, and pushes both', async () => {
|
|
89
|
+
await workspaces.getOrCreateForBranch('alice/a');
|
|
90
|
+
await workspaces.getOrCreateForBranch('bob/b');
|
|
91
|
+
await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
|
|
92
|
+
await fs.writeFile(path.join(checkoutOf('bob/b'), 'bob.md'), 'bob edits\n');
|
|
93
|
+
|
|
94
|
+
const [a, b] = await Promise.all([
|
|
95
|
+
gitService.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits'),
|
|
96
|
+
gitService.commitFile(id('bob/b'), ALICE, 'knowledge-base/bob.md', 'bob edits'),
|
|
97
|
+
]);
|
|
98
|
+
expect(a).not.toBeNull();
|
|
99
|
+
expect(b).not.toBeNull();
|
|
100
|
+
await Promise.all([gitService.push(id('alice/a'), ALICE), gitService.push(id('bob/b'), ALICE)]);
|
|
101
|
+
|
|
102
|
+
expect(await git(upstream, ['show', 'alice/a:alice.md'])).toBe('alice edits\n');
|
|
103
|
+
expect(await git(upstream, ['show', 'bob/b:bob.md'])).toBe('bob edits\n');
|
|
104
|
+
// Each push left its branch tracking its own remote branch.
|
|
105
|
+
expect((await git(checkoutOf('alice/a'), ['rev-parse', '--abbrev-ref', '@{upstream}'])).trim()).toBe('origin/alice/a');
|
|
106
|
+
expect((await git(checkoutOf('bob/b'), ['status', '--porcelain'])).trim()).toBe('');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("answers a change request's files and merges it exactly as a clone of the same remote does", async () => {
|
|
110
|
+
await workspaces.getOrCreateForBranch(TARGET);
|
|
111
|
+
await workspaces.getOrCreateForBranch('alice/a');
|
|
112
|
+
const onWorktrees = await gitService.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
|
|
113
|
+
|
|
114
|
+
// The same question asked of a full clone of the same remote.
|
|
115
|
+
const cloneRoot = path.join(root, 'clones');
|
|
116
|
+
const clone = path.join(cloneRoot, id(TARGET), 'knowledge-base');
|
|
117
|
+
await fs.mkdir(path.dirname(clone), { recursive: true });
|
|
118
|
+
await git(root, ['clone', '-q', '-b', TARGET, upstream, clone]);
|
|
119
|
+
const clones = newWorkspaceService(cloneRoot, upstream, testKbContext(), new NodeFs());
|
|
120
|
+
const onClones = await new GitService(clones, new WorkflowHooks(), testKbContext()).changedFilesForPr(
|
|
121
|
+
id(TARGET), TARGET, 'alice/a',
|
|
122
|
+
);
|
|
123
|
+
expect(onWorktrees).toEqual(onClones);
|
|
124
|
+
|
|
125
|
+
await gitService.mergeChangeRequest(
|
|
126
|
+
id(TARGET), 'alice/a', TARGET, { subject: 'Merge alice/a (#1)', body: '' }, ALICE,
|
|
127
|
+
);
|
|
128
|
+
expect(await git(upstream, ['show', `${TARGET}:alice.md`])).toBe('alice/a\n');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('deletes a branch whose own worktree is open, and removing that workspace takes the worktree and its ref', async () => {
|
|
132
|
+
await workspaces.getOrCreateForBranch(TARGET);
|
|
133
|
+
await workspaces.getOrCreateForBranch('alice/a');
|
|
134
|
+
|
|
135
|
+
await gitService.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
|
|
136
|
+
await workspaces.deleteWorkspace(id('alice/a'));
|
|
137
|
+
|
|
138
|
+
await expect(git(upstream, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
|
|
139
|
+
await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/alice/a'])).rejects.toThrow();
|
|
140
|
+
await expect(fs.access(checkoutOf('alice/a'))).rejects.toThrow();
|
|
141
|
+
expect((await git(checkoutOf(TARGET), ['status', '--porcelain'])).trim()).toBe('');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("finishes a branch's first open raced by git operations on that branch, and a fetch joined from inside one", async () => {
|
|
145
|
+
// The first request for a branch fans out: the page opens the workspace
|
|
146
|
+
// while the git layer asks for its status — inside the branch's
|
|
147
|
+
// reservation, resolving the same workspace — and a listing fetches.
|
|
148
|
+
// The worktree add waits for running git operations, and they wait for
|
|
149
|
+
// the open: nothing may wait for itself.
|
|
150
|
+
await workspaces.getOrCreateForBranch(TARGET);
|
|
151
|
+
const all = Promise.all([
|
|
152
|
+
workspaces.getOrCreateForBranch('bob/b'),
|
|
153
|
+
gitService.status(id('bob/b')),
|
|
154
|
+
gitService.listBranches(id(TARGET), { freshFetch: true }),
|
|
155
|
+
gitService.status(id('bob/b')),
|
|
156
|
+
]);
|
|
157
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
try {
|
|
159
|
+
await Promise.race([
|
|
160
|
+
all,
|
|
161
|
+
new Promise((_, reject) => {
|
|
162
|
+
timeout = setTimeout(() => reject(new Error('deadlocked')), 20_000);
|
|
163
|
+
}),
|
|
164
|
+
]);
|
|
165
|
+
} finally {
|
|
166
|
+
if (timeout) clearTimeout(timeout);
|
|
167
|
+
}
|
|
168
|
+
expect((await git(checkoutOf('bob/b'), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe('bob/b');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('lists branches from one fetch of the repository, strictly', async () => {
|
|
172
|
+
await workspaces.getOrCreateForBranch(TARGET);
|
|
173
|
+
const branches = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
|
|
174
|
+
expect(branches.map((b) => b.name).sort()).toEqual(['alice/a', 'bob/b', TARGET].sort());
|
|
175
|
+
expect(gitService.lastRemoteContact()?.ok).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not list another worktree's local branch once origin no longer has it, so the orphan sweep can take it", async () => {
|
|
179
|
+
await workspaces.getOrCreateForBranch(TARGET);
|
|
180
|
+
await workspaces.getOrCreateForBranch('bob/b');
|
|
181
|
+
// Deleted on the host while its worktree stays open: the repository
|
|
182
|
+
// still holds bob/b's local ref, as bob's clone held it.
|
|
183
|
+
await git(upstream, ['branch', '-D', 'bob/b']);
|
|
184
|
+
|
|
185
|
+
const fromTarget = await gitService.listBranches(id(TARGET), { freshFetch: true, strictFetch: true });
|
|
186
|
+
expect(fromTarget.map((b) => b.name).sort()).toEqual(['alice/a', TARGET].sort());
|
|
187
|
+
// A clone never saw another branch's local ref either: listed from its
|
|
188
|
+
// own worktree, a branch is its own.
|
|
189
|
+
const fromBob = await gitService.listBranches(id('bob/b'), { freshFetch: true });
|
|
190
|
+
expect(fromBob.find((b) => b.name === 'bob/b')?.hasRemote).toBe(false);
|
|
191
|
+
|
|
192
|
+
const { removed } = await workspaces.sweepOrphanedWorkspaces(fromTarget.map((b) => b.name));
|
|
193
|
+
expect(removed).toEqual([id('bob/b')]);
|
|
194
|
+
await expect(git(workspaces.repository.dir, ['rev-parse', '--verify', 'refs/heads/bob/b'])).rejects.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('GitService and the repository administrator', () => {
|
|
199
|
+
/** The repository, noting whether its administrator's section is held while git runs. */
|
|
200
|
+
class RecordingRepository extends KbRepository {
|
|
201
|
+
depth = 0;
|
|
202
|
+
override exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
203
|
+
return super.exclusive(async () => {
|
|
204
|
+
this.depth += 1;
|
|
205
|
+
try { return await fn(); } finally { this.depth -= 1; }
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it('writes the shared config as the administrator, and runs everything else as itself', async () => {
|
|
211
|
+
const mutex = new WorkspaceMutex();
|
|
212
|
+
const runner = new NodeGitRunner();
|
|
213
|
+
const repository = new RecordingRepository({
|
|
214
|
+
dir: repositoryDirFor(workspacesRoot, 'knowledge-base'),
|
|
215
|
+
kbRepoUrl: () => upstream,
|
|
216
|
+
gitRunner: runner,
|
|
217
|
+
});
|
|
218
|
+
const recorded = newWorkspaceService(
|
|
219
|
+
workspacesRoot, upstream, testKbContext(), new NodeFs(), runner, undefined, undefined, repository,
|
|
220
|
+
);
|
|
221
|
+
const calls: { args: string[]; held: boolean }[] = [];
|
|
222
|
+
const recordingRunner = {
|
|
223
|
+
defaultTimeoutMs: runner.defaultTimeoutMs,
|
|
224
|
+
credentials: runner.credentials,
|
|
225
|
+
run: ((cwd: string, args: string[], opts?: never) => {
|
|
226
|
+
calls.push({ args, held: repository.depth > 0 });
|
|
227
|
+
return runner.run(cwd, args, opts);
|
|
228
|
+
}) as NodeGitRunner['run'],
|
|
229
|
+
};
|
|
230
|
+
const service = new GitService(recorded, new WorkflowHooks(), testKbContext(), mutex, null, recordingRunner);
|
|
231
|
+
await recorded.getOrCreateForBranch(TARGET);
|
|
232
|
+
await recorded.getOrCreateForBranch('alice/a');
|
|
233
|
+
|
|
234
|
+
await service.fetch(id(TARGET));
|
|
235
|
+
await service.changedFilesForPr(id(TARGET), TARGET, 'alice/a');
|
|
236
|
+
await fs.writeFile(path.join(checkoutOf('alice/a'), 'alice.md'), 'alice edits\n');
|
|
237
|
+
await service.commitFile(id('alice/a'), ALICE, 'knowledge-base/alice.md', 'alice edits');
|
|
238
|
+
await service.push(id('alice/a'), ALICE);
|
|
239
|
+
await service.status(id('alice/a'));
|
|
240
|
+
// A merge stamps the committer's name and email into the config.
|
|
241
|
+
const merged = await service.mergeChangeRequest(
|
|
242
|
+
id(TARGET), 'alice/a', TARGET, { subject: 'alice edits (#1)', body: '' }, ALICE,
|
|
243
|
+
);
|
|
244
|
+
expect(merged.kind).toBe('merged');
|
|
245
|
+
await service.deleteBranch(id(TARGET), 'alice/a', ALICE, { systemCleanup: true });
|
|
246
|
+
|
|
247
|
+
// The subcommand, past leading `-c key=value` / `-C dir` options.
|
|
248
|
+
const sub = (args: string[]) => {
|
|
249
|
+
let i = 0;
|
|
250
|
+
while (args[i] === '-c' || args[i] === '-C') i += 2;
|
|
251
|
+
return { name: args[i], rest: args.slice(i + 1) };
|
|
252
|
+
};
|
|
253
|
+
const of = (pred: (s: { name: string; rest: string[] }) => boolean) => calls.filter((c) => pred(sub(c.args)));
|
|
254
|
+
|
|
255
|
+
// The config is the one file git refuses a second writer of: every write
|
|
256
|
+
// to it (the upstream stamp, the committer's name and email, a branch
|
|
257
|
+
// delete that drops the branch's section) is the administrator's. The
|
|
258
|
+
// refs, packed-refs and the objects git locks itself, so a fetch, a
|
|
259
|
+
// commit, a push, a merge and a read run as any worktree's git would.
|
|
260
|
+
const configRead = (rest: string[]) => rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(a));
|
|
261
|
+
const shared = [
|
|
262
|
+
...of((s) => s.name === 'config' && !configRead(s.rest)),
|
|
263
|
+
...of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')),
|
|
264
|
+
];
|
|
265
|
+
const own = [
|
|
266
|
+
...of((s) => s.name === 'fetch'),
|
|
267
|
+
...of((s) => s.name === 'commit'),
|
|
268
|
+
...of((s) => s.name === 'merge'),
|
|
269
|
+
...of((s) => s.name === 'push' && !s.rest.includes('-u')),
|
|
270
|
+
...of((s) => s.name === 'status'),
|
|
271
|
+
...of((s) => s.name === 'config' && configRead(s.rest)),
|
|
272
|
+
];
|
|
273
|
+
for (const name of ['fetch', 'config', 'commit', 'merge', 'push', 'status']) {
|
|
274
|
+
expect(calls.some((c) => sub(c.args).name === name), name).toBe(true);
|
|
275
|
+
}
|
|
276
|
+
expect(of((s) => s.name === 'config' && s.rest.includes('--replace-all')).length).toBeGreaterThan(0);
|
|
277
|
+
expect(of((s) => s.name === 'config' && s.rest[0] === 'user.name').length).toBeGreaterThan(0);
|
|
278
|
+
expect(of((s) => s.name === 'config' && s.rest[0] === 'user.email').length).toBeGreaterThan(0);
|
|
279
|
+
expect(of((s) => s.name === 'branch' && s.rest.some((a) => a === '-D' || a === '-d')).length).toBeGreaterThan(0);
|
|
280
|
+
expect(shared.length).toBeGreaterThan(3);
|
|
281
|
+
for (const c of shared) expect(c.held, c.args.join(' ')).toBe(true);
|
|
282
|
+
for (const c of own) expect(c.held, c.args.join(' ')).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -25,6 +25,7 @@ import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
|
25
25
|
import { isAbsence } from '../../../shared/fs.contract.js';
|
|
26
26
|
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
27
27
|
import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
|
|
28
|
+
import { readRefSha } from '../../kb-fs/index.js';
|
|
28
29
|
import {
|
|
29
30
|
assertValidBranchName,
|
|
30
31
|
assertValidRelativePath,
|
|
@@ -294,6 +295,25 @@ export class GitService implements IGitService {
|
|
|
294
295
|
// counts, calm under large branch lists.
|
|
295
296
|
private static readonly MAX_AHEAD_BEHIND_CONCURRENCY = 8;
|
|
296
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A change request's changed entries, keyed by the two commits its diff
|
|
300
|
+
* was read between. Two commits never change, and neither does the
|
|
301
|
+
* merge-base between them, so the three-dot diff of a pair is a fact: a
|
|
302
|
+
* push moves a tip, the pair changes, the old entry is simply never asked
|
|
303
|
+
* for again. No TTL, no invalidation.
|
|
304
|
+
*
|
|
305
|
+
* This is what a change-request listing was paying git for on every read:
|
|
306
|
+
* one `diff` per open request, polled every minute and refetched on every
|
|
307
|
+
* event, where nothing had moved. Now git is asked once per pair, which is
|
|
308
|
+
* once per push. Keyed by commits alone, not by workspace: every clone of
|
|
309
|
+
* the knowledge base holds the same objects, so the answer is the same in
|
|
310
|
+
* all of them. Bounded FIFO; the bound only caps memory.
|
|
311
|
+
*/
|
|
312
|
+
private readonly changedEntriesByCommits = new Map<string, NameStatusEntry[]>();
|
|
313
|
+
/** Diffs being read right now, so concurrent misses on one pair share one `git diff`. */
|
|
314
|
+
private readonly changedEntriesInFlight = new Map<string, Promise<NameStatusEntry[]>>();
|
|
315
|
+
private static readonly CHANGED_ENTRIES_MAX = 4096;
|
|
316
|
+
|
|
297
317
|
constructor(
|
|
298
318
|
private readonly workspaceService: WorkspaceService,
|
|
299
319
|
/**
|
|
@@ -378,7 +398,9 @@ export class GitService implements IGitService {
|
|
|
378
398
|
* above rather than kept separately, so it cannot disagree with it.
|
|
379
399
|
*/
|
|
380
400
|
lastRemoteContact(): { at: number; ok: boolean } | null {
|
|
381
|
-
|
|
401
|
+
// The repository's fetches are every worktree's; the per-workspace record
|
|
402
|
+
// is a full clone's.
|
|
403
|
+
let latest: { at: number; ok: boolean } | null = this.workspaceService.repository?.lastContact() ?? null;
|
|
382
404
|
for (const [workspaceId, at] of this.lastImplicitFetchAt) {
|
|
383
405
|
if (latest === null || at > latest.at) {
|
|
384
406
|
latest = { at, ok: this.lastImplicitFetchOk.get(workspaceId) ?? false };
|
|
@@ -495,12 +517,27 @@ export class GitService implements IGitService {
|
|
|
495
517
|
// Union local heads with origin's remote-tracking refs: a fresh per-user
|
|
496
518
|
// clone only has one local head (HEAD's default), so without this we'd hide
|
|
497
519
|
// target-company-state and every draft pushed by another user.
|
|
520
|
+
//
|
|
521
|
+
// The workspace is a worktree of the one repository, which holds every
|
|
522
|
+
// OTHER worktree's local branch too. Those are not this workspace's: a
|
|
523
|
+
// clone never had them. A local head checked out in another worktree
|
|
524
|
+
// is read as that branch's remote-tracking ref alone (and not at all
|
|
525
|
+
// once origin no longer has it), so a branch deleted on origin is not
|
|
526
|
+
// kept alive by its own worktree's local ref — the orphan sweep reads
|
|
527
|
+
// this list. A branch is checked out in at most one worktree, so "not
|
|
528
|
+
// this one's HEAD" is "another's".
|
|
498
529
|
const { stdout } = await this.git(cwd, [
|
|
499
530
|
'for-each-ref',
|
|
500
|
-
'--format=%(refname:short)%09%(refname)',
|
|
531
|
+
'--format=%(refname:short)%09%(refname)%09%(worktreepath)',
|
|
501
532
|
'refs/heads',
|
|
502
533
|
'refs/remotes/origin',
|
|
503
534
|
]);
|
|
535
|
+
const ownBranch = (
|
|
536
|
+
await this.git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(
|
|
537
|
+
(r) => r.stdout,
|
|
538
|
+
() => '',
|
|
539
|
+
)
|
|
540
|
+
).trim();
|
|
504
541
|
|
|
505
542
|
type Row = { shortName: string; ref: string; hasLocal: boolean; hasRemote: boolean };
|
|
506
543
|
const buckets = new Map<string, Row>();
|
|
@@ -510,7 +547,7 @@ export class GitService implements IGitService {
|
|
|
510
547
|
const tab = trimmed.indexOf('\t');
|
|
511
548
|
if (tab < 0) continue;
|
|
512
549
|
const rawShort = trimmed.slice(0, tab);
|
|
513
|
-
const ref = trimmed.slice(tab + 1);
|
|
550
|
+
const [ref = '', worktreePath = ''] = trimmed.slice(tab + 1).split('\t');
|
|
514
551
|
if (!rawShort || !ref) continue;
|
|
515
552
|
// Skip symbolic refs up-front. Different git versions report
|
|
516
553
|
// `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
|
|
@@ -521,6 +558,7 @@ export class GitService implements IGitService {
|
|
|
521
558
|
const isLocal = ref.startsWith('refs/heads/');
|
|
522
559
|
const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
|
|
523
560
|
if (!shortName) continue;
|
|
561
|
+
if (isLocal && worktreePath !== '' && shortName !== ownBranch) continue;
|
|
524
562
|
// Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
|
|
525
563
|
// if a future git version lands us here, don't bucket it as a branch.
|
|
526
564
|
if (shortName === 'HEAD') continue;
|
|
@@ -614,6 +652,20 @@ export class GitService implements IGitService {
|
|
|
614
652
|
workspaceId: string,
|
|
615
653
|
force = false,
|
|
616
654
|
): Promise<boolean> {
|
|
655
|
+
// A worktree's remote-tracking refs are the repository's: ONE fetch for
|
|
656
|
+
// every workspace, throttled, joined and reserved there. The per-workspace
|
|
657
|
+
// record below is for a full clone from an earlier release, which fetches
|
|
658
|
+
// on its own until the boot phase converts it.
|
|
659
|
+
const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
|
|
660
|
+
if (repository) {
|
|
661
|
+
return repository.fetch({
|
|
662
|
+
maxAgeMs: GitService.IMPLICIT_FETCH_TTL_MS,
|
|
663
|
+
// A host that is down is not asked again by every poll inside the
|
|
664
|
+
// window, as the per-workspace record below stamps a failure too.
|
|
665
|
+
failureBackoffMs: GitService.IMPLICIT_FETCH_TTL_MS,
|
|
666
|
+
force,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
617
669
|
const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
|
|
618
670
|
if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS) {
|
|
619
671
|
return this.lastImplicitFetchOk.get(workspaceId) ?? true;
|
|
@@ -692,8 +744,9 @@ export class GitService implements IGitService {
|
|
|
692
744
|
// navigation to the new draft's URL fails with
|
|
693
745
|
// "Remote branch <name> not found in upstream origin". Roll the local
|
|
694
746
|
// ref back on failure so a retry isn't blocked by "branch already exists".
|
|
747
|
+
// No `-u`: the local head is dropped right below, and its upstream with it.
|
|
695
748
|
try {
|
|
696
|
-
await this.git(cwd, ['push', '
|
|
749
|
+
await this.git(cwd, ['push', 'origin', name]);
|
|
697
750
|
} catch (err) {
|
|
698
751
|
await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
|
|
699
752
|
throw err;
|
|
@@ -829,7 +882,14 @@ export class GitService implements IGitService {
|
|
|
829
882
|
let localTip: string | null = null;
|
|
830
883
|
if (await this.refExists(cwd, `refs/heads/${name}`)) {
|
|
831
884
|
localTip = await this.revParseOrNull(cwd, `refs/heads/${name}`);
|
|
832
|
-
await this.git(cwd, ['branch', '-D', name])
|
|
885
|
+
await this.git(cwd, ['branch', '-D', name]).catch((err: unknown) => {
|
|
886
|
+
// In a repository of worktrees the branch's local ref is shared, and
|
|
887
|
+
// git keeps it while the branch's own workspace has it checked out.
|
|
888
|
+
// Removing that workspace — which the caller does next — takes the
|
|
889
|
+
// worktree and the ref with it.
|
|
890
|
+
if (/checked out at|used by worktree/i.test(err instanceof Error ? err.message : String(err))) return;
|
|
891
|
+
throw err;
|
|
892
|
+
});
|
|
833
893
|
}
|
|
834
894
|
return { lastCommit: remoteTip ?? localTip };
|
|
835
895
|
});
|
|
@@ -1409,7 +1469,14 @@ export class GitService implements IGitService {
|
|
|
1409
1469
|
);
|
|
1410
1470
|
}
|
|
1411
1471
|
|
|
1412
|
-
|
|
1472
|
+
// No `-u`: it writes the upstream into the config during the push, and
|
|
1473
|
+
// a worktree's config is the repository's, shared by every workspace —
|
|
1474
|
+
// git refuses a second writer of it rather than waiting. The upstream is
|
|
1475
|
+
// stamped after the push instead, as one short write made as the
|
|
1476
|
+
// repository's administrator (see `git`).
|
|
1477
|
+
await this.git(cwd, ['push', 'origin', branch]);
|
|
1478
|
+
await this.git(cwd, ['config', '--replace-all', `branch.${branch}.remote`, 'origin']);
|
|
1479
|
+
await this.git(cwd, ['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
1413
1480
|
}
|
|
1414
1481
|
}
|
|
1415
1482
|
|
|
@@ -3166,9 +3233,8 @@ export class GitService implements IGitService {
|
|
|
3166
3233
|
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
3167
3234
|
}
|
|
3168
3235
|
return this.mutex.run(workspaceId, async () => {
|
|
3169
|
-
const
|
|
3170
|
-
|
|
3171
|
-
return this.prChangedPathsAt(cwd, baseRef, headRef, opts);
|
|
3236
|
+
const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
|
|
3237
|
+
return flattenChangedPaths(await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head), opts);
|
|
3172
3238
|
});
|
|
3173
3239
|
}
|
|
3174
3240
|
|
|
@@ -3243,13 +3309,51 @@ export class GitService implements IGitService {
|
|
|
3243
3309
|
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
3244
3310
|
}
|
|
3245
3311
|
return this.mutex.run(workspaceId, async () => {
|
|
3246
|
-
const
|
|
3247
|
-
const
|
|
3248
|
-
const entries = await this.prChangedEntriesAt(cwd, baseRef, headRef);
|
|
3312
|
+
const at = pinned ?? (await this.publishedPrEnds(cwd, baseBranch, headBranch));
|
|
3313
|
+
const entries = await this.prChangedEntriesBetweenCommits(cwd, at.base, at.head);
|
|
3249
3314
|
return { paths: flattenChangedPaths(entries, {}), pairs: changedPathPairs(entries) };
|
|
3250
3315
|
});
|
|
3251
3316
|
}
|
|
3252
3317
|
|
|
3318
|
+
/**
|
|
3319
|
+
* {@link prChangedEntriesAt} for a pair of COMMITS, read once per pair
|
|
3320
|
+
* (see `changedEntriesByCommits`). The caller holds the reservation, as for
|
|
3321
|
+
* `prChangedEntriesAt`; a memo hit holds it for nothing, which is cheaper
|
|
3322
|
+
* than a second code path around the lock.
|
|
3323
|
+
*/
|
|
3324
|
+
private async prChangedEntriesBetweenCommits(
|
|
3325
|
+
cwd: string,
|
|
3326
|
+
baseSha: string,
|
|
3327
|
+
headSha: string,
|
|
3328
|
+
): Promise<NameStatusEntry[]> {
|
|
3329
|
+
const key = `${baseSha}\0${headSha}`;
|
|
3330
|
+
const known = this.changedEntriesByCommits.get(key);
|
|
3331
|
+
if (known) return known;
|
|
3332
|
+
let pending = this.changedEntriesInFlight.get(key);
|
|
3333
|
+
if (!pending) {
|
|
3334
|
+
pending = this.prChangedEntriesAt(cwd, baseSha, headSha)
|
|
3335
|
+
.then((entries) => {
|
|
3336
|
+
if (this.changedEntriesByCommits.size >= GitService.CHANGED_ENTRIES_MAX) {
|
|
3337
|
+
const oldest = this.changedEntriesByCommits.keys().next().value;
|
|
3338
|
+
if (oldest !== undefined) this.changedEntriesByCommits.delete(oldest);
|
|
3339
|
+
}
|
|
3340
|
+
this.changedEntriesByCommits.set(key, entries);
|
|
3341
|
+
return entries;
|
|
3342
|
+
})
|
|
3343
|
+
.finally(() => {
|
|
3344
|
+
if (this.changedEntriesInFlight.get(key) === pending) this.changedEntriesInFlight.delete(key);
|
|
3345
|
+
});
|
|
3346
|
+
// Same bound as the memo: evicting an in-flight entry costs at most one
|
|
3347
|
+
// redundant diff for that pair, never a lost answer.
|
|
3348
|
+
if (this.changedEntriesInFlight.size >= GitService.CHANGED_ENTRIES_MAX) {
|
|
3349
|
+
const oldest = this.changedEntriesInFlight.keys().next().value;
|
|
3350
|
+
if (oldest !== undefined) this.changedEntriesInFlight.delete(oldest);
|
|
3351
|
+
}
|
|
3352
|
+
this.changedEntriesInFlight.set(key, pending);
|
|
3353
|
+
}
|
|
3354
|
+
return pending;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3253
3357
|
/**
|
|
3254
3358
|
* The merge-base commit of a change request's two branches (their origin
|
|
3255
3359
|
* refs), or null when they share no history. This is the "before" the CR's
|
|
@@ -3590,16 +3694,23 @@ export class GitService implements IGitService {
|
|
|
3590
3694
|
baseBranch: string,
|
|
3591
3695
|
headBranch: string,
|
|
3592
3696
|
): Promise<{ base: string; head: string } | null> {
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
if (err instanceof WorkflowValidationError) return null;
|
|
3599
|
-
throw err;
|
|
3600
|
-
}
|
|
3697
|
+
try {
|
|
3698
|
+
return await this.publishedPrEnds(cwd, baseBranch, headBranch);
|
|
3699
|
+
} catch (err) {
|
|
3700
|
+
if (err instanceof WorkflowValidationError) return null;
|
|
3701
|
+
throw err;
|
|
3601
3702
|
}
|
|
3602
|
-
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/** The two commits a change request is read between, each resolved as {@link resolvePublishedBranch} does. */
|
|
3706
|
+
private async publishedPrEnds(
|
|
3707
|
+
cwd: string,
|
|
3708
|
+
baseBranch: string,
|
|
3709
|
+
headBranch: string,
|
|
3710
|
+
): Promise<{ base: string; head: string }> {
|
|
3711
|
+
const base = (await this.resolvePublishedBranch(cwd, baseBranch)).sha;
|
|
3712
|
+
const head = (await this.resolvePublishedBranch(cwd, headBranch)).sha;
|
|
3713
|
+
return { base, head };
|
|
3603
3714
|
}
|
|
3604
3715
|
|
|
3605
3716
|
/**
|
|
@@ -3625,6 +3736,13 @@ export class GitService implements IGitService {
|
|
|
3625
3736
|
branch: string,
|
|
3626
3737
|
): Promise<{ ref: string; sha: string }> {
|
|
3627
3738
|
for (const ref of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
|
|
3739
|
+
// The ref file first (`readRefSha`): no process, same answer, same
|
|
3740
|
+
// freshness. Git is asked only for what the files cannot decide — a
|
|
3741
|
+
// missing ref is decided by the files and the loop moves on; an
|
|
3742
|
+
// unreadable one is git's to answer, as the authority.
|
|
3743
|
+
const fromFiles = await readRefSha(cwd, ref);
|
|
3744
|
+
if (fromFiles === 'missing') continue;
|
|
3745
|
+
if (fromFiles !== 'unreadable') return { ref, sha: fromFiles.sha };
|
|
3628
3746
|
try {
|
|
3629
3747
|
const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
|
|
3630
3748
|
return { ref, sha: stdout.trim() };
|
|
@@ -4017,6 +4135,29 @@ export class GitService implements IGitService {
|
|
|
4017
4135
|
return stdout.split('\0').filter((line) => line.length > 0);
|
|
4018
4136
|
}
|
|
4019
4137
|
|
|
4138
|
+
/**
|
|
4139
|
+
* The commit `ref` names right now, or null when it names none (an unborn
|
|
4140
|
+
* `HEAD`, a ref that does not exist). A caller that reads several things
|
|
4141
|
+
* "at HEAD" pins the commit once with this and asks each read at that
|
|
4142
|
+
* commit, so a commit landing between the reads cannot hand it one
|
|
4143
|
+
* snapshot's files with another's rules.
|
|
4144
|
+
*
|
|
4145
|
+
* Null is ONLY "names no commit": `rev-parse --verify --quiet` says that
|
|
4146
|
+
* with exit status 1 and nothing on stderr. Any other failure — no
|
|
4147
|
+
* repository there, git itself failing, a deadline — is thrown, so a
|
|
4148
|
+
* caller cannot read a broken clone as an empty one.
|
|
4149
|
+
*/
|
|
4150
|
+
async commitAtRef(workspaceId: string, ref: string): Promise<string | null> {
|
|
4151
|
+
const cwd = await this.repoDir(workspaceId);
|
|
4152
|
+
try {
|
|
4153
|
+
const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
4154
|
+
return stdout.trim() || null;
|
|
4155
|
+
} catch (err) {
|
|
4156
|
+
if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && !(err.stderr ?? '').trim()) return null;
|
|
4157
|
+
throw err;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4020
4161
|
/**
|
|
4021
4162
|
* A clean `git status` for a path has two honest readings (already
|
|
4022
4163
|
* committed; a queued re-apply of something that landed) and one dishonest
|
|
@@ -4074,7 +4215,7 @@ export class GitService implements IGitService {
|
|
|
4074
4215
|
* from a real failure are unaffected.
|
|
4075
4216
|
*/
|
|
4076
4217
|
private git(cwd: string, args: string[], opts?: Omit<GitRunOptions, 'encoding'>): Promise<GitRunResult> {
|
|
4077
|
-
|
|
4218
|
+
const run = () => this.gitRunner.run(cwd, args, {
|
|
4078
4219
|
...opts,
|
|
4079
4220
|
// `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
|
|
4080
4221
|
// instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as glob
|
|
@@ -4089,6 +4230,18 @@ export class GitService implements IGitService {
|
|
|
4089
4230
|
// a search for a file literally named that.
|
|
4090
4231
|
env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
|
|
4091
4232
|
});
|
|
4233
|
+
// A write to the git config is a write to the ONE file every worktree of
|
|
4234
|
+
// the repository shares, which git refuses a second writer of rather than
|
|
4235
|
+
// waiting: it runs as the repository's administrator. Everything else —
|
|
4236
|
+
// a fetch, a commit, a branch delete — git serializes with its own locks,
|
|
4237
|
+
// as it does for any two worktrees of one repository.
|
|
4238
|
+
return writesSharedConfig(args) ? this.asRepositoryAdministrator(cwd, run) : run();
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
/** `run` as the repository's administrator when `cwd` is a worktree of it; as itself for a full clone. */
|
|
4242
|
+
private async asRepositoryAdministrator<T>(cwd: string, run: () => Promise<T>): Promise<T> {
|
|
4243
|
+
const repository = (await this.workspaceService.repositoryOfCheckout?.(cwd)) ?? null;
|
|
4244
|
+
return repository ? repository.exclusive(run) : run();
|
|
4092
4245
|
}
|
|
4093
4246
|
|
|
4094
4247
|
/**
|
|
@@ -4276,3 +4429,35 @@ export function parseNumstatZ(out: string): NumstatEntry[] {
|
|
|
4276
4429
|
}
|
|
4277
4430
|
return entries;
|
|
4278
4431
|
}
|
|
4432
|
+
|
|
4433
|
+
/**
|
|
4434
|
+
* Whether a git command WRITES the config — the one file every worktree of
|
|
4435
|
+
* the knowledge base's repository shares, which git refuses a second writer
|
|
4436
|
+
* of rather than waiting — and so must run as the repository's administrator
|
|
4437
|
+
* (`IKbRepository.exclusive`): a `config` that sets or unsets, a `remote`
|
|
4438
|
+
* change, a branch rename, delete or upstream change (its config section is
|
|
4439
|
+
* written, or removed with the branch), a push that sets the upstream. Reads
|
|
4440
|
+
* of the config are not writes. Nothing else is: the refs, `packed-refs` and
|
|
4441
|
+
* the object store are locked by git itself, and the worktree list is the
|
|
4442
|
+
* repository's own to serialize (`KbRepository`).
|
|
4443
|
+
*
|
|
4444
|
+
* Leading `-c key=value` / `-C dir` options are skipped to find the subcommand.
|
|
4445
|
+
*/
|
|
4446
|
+
function writesSharedConfig(args: readonly string[]): boolean {
|
|
4447
|
+
let i = 0;
|
|
4448
|
+
while (i < args.length && (args[i] === '-c' || args[i] === '-C')) i += 2;
|
|
4449
|
+
const sub = args[i];
|
|
4450
|
+
const rest = args.slice(i + 1);
|
|
4451
|
+
switch (sub) {
|
|
4452
|
+
case 'remote':
|
|
4453
|
+
return true;
|
|
4454
|
+
case 'config':
|
|
4455
|
+
return !rest.some((a) => /^(--get|--get-all|--get-regexp|--list|-l|--show-origin)$/.test(a));
|
|
4456
|
+
case 'branch':
|
|
4457
|
+
return rest.some((a) => /^(-d|-D|--delete|-m|-M|--move|-u|--set-upstream-to=.*|--unset-upstream)$/.test(a));
|
|
4458
|
+
case 'push':
|
|
4459
|
+
return rest.some((a) => a === '-u' || a === '--set-upstream');
|
|
4460
|
+
default:
|
|
4461
|
+
return false;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the workflow module offers the other modules: the addresses of its
|
|
3
|
+
* change requests, so a module that lands a proposal can hand back a link
|
|
4
|
+
* built by the one rule — rather than each spelling the route for itself.
|
|
5
|
+
* The services are wired by the composition root.
|
|
6
|
+
*/
|
|
7
|
+
export { CHANGE_REQUEST_URL_NOTE, changeRequestLink, changeRequestLinkBase } from './git/change-request-link.js';
|